Julia 語言由於「快」和「簡潔」可兼得而聞名,咱們能夠用相似 Python 的優美語句得到相似 C 的性能。那麼你知道爲何 Julia 比 Python 快嗎?這並非由於更好的編譯器,而是一種更新的設計理念,關注「人生苦短」的 Python 並無將這種理念歸入其中。
編程選自Github,機器之心編譯,參與:思源、李亞洲。數組
其實像之前 C 或其它主流語言在使用變量前先要聲明變量的具體類型,而 Python 並不須要,賦值什麼數據,變量就是什麼類型。然而沒想到正是這種類型穩定性,讓 Julia 相比 Python 有更好的性能。緩存
選擇 Julia 的最主要緣由:要比其餘腳本語言快得多,讓你擁有 Python/Matlab /R 同樣快速的開發速度,同時像 C/Fortan 那樣高效的運行速度。安全
Julia 的新手可能對下面這些描述略爲謹慎:bash
爲何其餘語言不能更快一點?Julia 可以作到,其餘語言就不能?
編程語言
你怎麼解釋 Julia 的速度基準?(對許多其餘語言來講也很難?)函數
這聽起來違背沒有免費午飯定律,在其餘方面是否有損失?工具
許多人認爲 Julia 快是由於它使用的是 JIT 編譯器,即每一條語句在使用前都先使用編譯函數進行編譯,不管是預先立刻編譯或以前先緩存編譯。這就產生了一個問題,即 Python/R 和 MATLAB 等腳本語言一樣可使用 JIT 編譯器,這些編譯器的優化時間甚至比 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 的編譯作特化頗有必要,使其工做效率很是高。此外,魯棒性的類型系統一樣還須要在細粒度水平的類型上正常運行,所以才能儘量實現類型穩定性,並在類型不穩定的狀況下儘量得到更高的優化。