什麼要選擇 Julia?由於它比其餘腳本語言更快,它在具有 Python、MATLAB、R 語言開發速度的同時,又能生成與 C 語言和 Fortran 同樣快的代碼。程序員
但 Julia 新手對這種說法可能會有點懷疑。 編程
爲何其餘腳本語言不也提高一下速度?Julia 能夠作到的,爲何其餘腳本語言作不到?數組
你能提供基準測試來證實它的速度嗎?緩存
這彷佛有違「天底下沒有免費的午飯」的道理。它真的有那麼完美嗎?安全
不少人認爲 Julia 運行速度很快,由於它是即時編譯(JIT)型的(也就是說,每條語句都使用編譯的函數來運行,這些函數要麼在使用以前進行即時編譯,要麼在以前已經編譯過並放在緩存中)。這就引出了一個問題:Julia 是否提供了比 Python 或 R 語言(MATLAB 默認使用 JIT)更好的 JIT 實現?由於人們在這些 JIT 編譯器上所作的工做比 Julia 要多得多,因此咱們憑什麼認爲 Julia 這麼快就會超過這些編譯器?但其實這徹底是對 Julia 的誤解。性能優化
我想以一種很是直觀的方式說明,Julia 的速度之因此快,是由於它的設計決策。Julia 的的核心設計決策是經過多重分派實現專門化的類型穩定性,編譯器所以能夠很容易地生成高效的代碼,同時還可以保持代碼的簡潔,讓它「看起來就像一門腳本語言」。app
可是,在本文的示例中,咱們將看到 Julia 並不老是像其餘腳本語言那樣,咱們必須接受「午飯不全是免費」的事實。編程語言
要看出它們之間的區別,咱們只須要看看基本的數學運算。ide
Julia 中的數學運算函數
通常來講,Julia 中的數學運算與其餘腳本語言中的數學運算看起來是同樣的。它們的數字都是「真正的數字」,好比 Float64 就是 64 位浮點數或者相似於 C 語言中的「double」。Vector{Float64}與 C 語言 double 數組的內存佈局是同樣的,均可以很容易地與 C 語言進行互操做(實際上,在某種意義上,「Julia 是構建在 C 語言之上的一個層」),從而帶來更高的性能。
使用 Julia 進行一些數學運算:
a = 2+2
b = a/3
c = a÷3 #\div tab completion, means integer division
d = 4*5
println([a;b;c;d])
[4.0, 1.33333, 1.0, 20.0]
我在這裏使用了 Julia 的 unicode 製表符補全功能。Julia 容許使用 unicode 字符,這些字符能夠經過製表符實現 Latex 風格的語句。一樣,若是一個數字後面跟着一個變量,那麼不須要使用 * 運算符就能夠進行乘法運算。例如,下面的 Julia 的代碼是合法的:
α = 0.5類型穩定性和代碼內省
∇f(u) = α*u; ∇f(2)
sin(2π)
-2.4492935982947064e-16
類型穩定性是指一個方法只能輸出一種可能的類型。例如:*(::Float64,::Float64) 輸出的類型是 Float64。無論你給它提供什麼參數,它都會返回一個 Float64。這裏使用了多重分派:「*」操做符根據它看到的類型調用不一樣的方法。例如,當它看到浮點數時,就會返回浮點數。Julia 提供了代碼自省宏,能夠看到代碼被編譯成什麼東西。所以,Julia 不僅是一門普通的腳本語言,仍是一門可讓你處理彙編的腳本語言!和其餘不少語言同樣,Julia 被編譯成 LLVM (LLVM 是一種可移植的彙編格式)。
@code_llvm 2*5
; Function *
; Location: int.jl:54
define i64 @"julia_*_33751"(i64, i64) {
top:
%2 = mul i64 %1, %0
ret i64 %2
}
這段代碼的意思是:執行一個浮點數乘法操做,而後返回結果。咱們也能夠看一下彙編代碼。
@code_native 2*5
.text
; Function * {
; Location: int.jl:54
imulq %rsi, %rdi
movq %rdi, %rax
retq
nopl (%rax,%rax)
;}
「*」函數被編譯成與 C 語言或 Fortran 中徹底相同的操做,這意味着它能夠達到相同的性能(儘管它是在 Julia 中定義的)。所以,Julia 不只能夠「接近」C 語言,並且實際上能夠獲得相同的 C 語言代碼。那麼在什麼狀況下會發生這種狀況?
Julia 的有趣之處在於,上面的這個問題其實問得不對,正確的問題應該是:在什麼狀況下代碼不能被編譯成像 C 語言或 Fortran 那樣?這裏的關鍵是類型穩定性。若是一個函數是類型穩定的,那麼編譯器就會知道函數在任意時刻的類型,就能夠巧妙地將其優化爲與 C 語言或 Fortran 相同的彙編代碼。若是它不是類型穩定的,Julia 必須進行昂貴的「裝箱」,以確保在操做以前知道函數的類型是什麼。
這是 Julia 與其餘腳本語言之間最關鍵的不一樣點。
好的方面是 Julia 的函數(類型穩定)基本上就是 C 語言或 Fortran 的函數,所以「^」(乘方)運算速度很快。那麼,類型穩定的 ^(::Int64,::Int64) 會輸出什麼?
2^5
32
2^-5
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
end
end
expo (generic function with 1 method)
如今運行一下看看行不行:
println(expo(2,5))
expo(2,-5)
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:60
L36:
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 經過使用類型推斷得到了比其餘腳本語言更高的性能。
核心思想:多重分派 + 類型穩定性 =>速度 + 可讀性
類型穩定性是 Julia 區別於其餘腳本語言的一個關鍵特性。事實上,Julia 的核心思想是這樣的:
多重分派容許一種語言將函數調用分派給類型穩定的函數。
這就是 Julia 的核心思想,如今讓咱們花點時間深刻了解一下。若是函數內部具備類型穩定性(也就是說,函數內的任意函數調用也是類型穩定的),那麼編譯器就會知道每一步的變量類型,它就能夠在編譯函數時進行充分的優化,這樣獲得的代碼基本上與 C 語言或 Fortran 相同。多重分派在這裏能夠起到做用,它意味着「*」能夠是一個類型穩定的函數:對於不一樣的輸入,它有不一樣的含義。可是,若是編譯器在調用「*」以前可以知道 a 和 b 的類型,那麼它就知道應該使用哪一個「*」方法,這樣它就知道 c=a*b 的輸出類型是什麼。這樣它就能夠將類型信息一路傳下去,從而實現全面的優化。
咱們從中能夠學到一些東西。首先,爲了實現這種級別的優化,必須具備類型穩定性。大多數語言爲了讓用戶能夠更輕鬆地編碼,都沒有在標準庫中提供這種特性。其次,須要經過多重分派來專門化類型函數,讓腳本語言語法「看上去更顯式」一些。最後,須要一個健壯的類型系統。爲了構建非類型穩定的乘方運算,咱們須要使用轉換函數。所以,要在保持腳本語言的語法和易用性的同時實現這種原始性能必須將語言設計成具備多重分派類型穩定性的語言,並提供一個健壯的類型系統。
Julia 基準測試
Julia 官網提供的基準測試只是針對編程語言組件的執行速度,並無說是在測試最快的實現,因此這裏存在一個很大的誤解。R 語言程序員一邊看着使用 R 語言實現的 Fibonacci 函數,一邊說:「這是一段很糟糕的代碼,不該該在 R 語言中使用遞歸,由於遞歸很慢」。但實際上,Fibonacci 函數是用來測試遞歸的,而不是用來測試語言的執行速度的。
Julia 使用了類型穩定函數的多重分派機制,所以,即便是早期版本的 Julia 也能夠優化得像 C 語言或 Fortran 那樣。很是明顯,幾乎在全部狀況下,Julia 都很是接近 C 語言。固然,也有與 C 語言不同的地方,咱們能夠來看看這些細節。首先是在計算 Fibonacci 數列時 C 語言比 Julia 快 2.11 倍,這是由於這是針對遞歸的測試,而 Julia 並無徹底爲遞歸進行過優化。Julia 其實也能夠加入這種優化(尾遞歸優化),只是出於某些緣由他們纔沒有這麼作,最主要是由於:可使用尾遞歸的地方也可使用循環,而循環是一種更加健壯的優化,因此他們建議使用循環來代替脆弱的尾遞歸。
Julia 表現不太好的地方還有 rand_mat_stat 和 parse_int 測試。這主要是由於邊界檢查致使的。在大多數腳本語言中,若是你試圖訪問超出數組邊界的元素就會出錯,Julia 默認狀況下也會這麼作。
function test1()
a = zeros(3)
for i=1:4
a[i] = i
end
end
test1()
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
不過,你可使用 @inbounds 宏來禁用這個功能:
function test2()
a = zeros(3)
@inbounds for i=1:4
a[i] = i
end
end
test2()
這樣你就得到了與 C 語言或 Fortran 同樣的不安全行爲和執行速度。這是 Julia 的另外一個有趣的特性:默認狀況下是一個安全的腳本語言特性,在必要的時候禁用這個功能,以便得到性能提高。
嚴格類型
除了類型穩定性,你還須要嚴格類型。在 Python 中,你能夠將任何東西放入數組中。而在 Julia 中,你只能將類型 T 放入 Vector{T}中。Julia 提供了各類非嚴格的類型,例如 Any。若是有必要,能夠建立 Vector{Any},例如:
a = Vector{Any}(undef,3)
a[1] = 1.0
a[2] = "hi!"
a[3] = :Symbolic
a
3-element Array{Any,1}:
1.0
"hi!"
:Symbolic
Union 是另外一個不那麼極端的抽象類型,例如:
a = Vector{Union{Float64,Int}}(undef,3)
a[1] = 1.0
a[2] = 3
a[3] = 1/4
a
3-element Array{Union{Float64, Int64},1}:
1.0
3
0.25
這個 Union 只接受浮點數和整數。不過,它仍然是一個抽象類型。接受抽象類型做爲參數的函數沒法知道元素的類型(在這個例子中,元素要麼是浮點數,要麼是整數),這個時候,多重分派優化在這裏起不到做用,因此 Julia 此時的性能就不如其餘腳本語言。
因此咱們能夠得出一個性能原則:儘量使用嚴格類型。使用嚴格類型還有其餘好處:嚴格類型的 Vector{Float64}實際上與 C 語言或 Fortran 是字節兼容的,因此不通過轉換就能夠直接用在 C 語言或 Fortran 程序中。
難免費的午飯
很明顯,Julia 爲了在保持腳本語言特徵的同時實現性能目標,作出了很是明智的設計決策。可是,它也爲此付出了一些代價。接下來,我將展現 Julia 的一些奇特的東西及其相應的工具。
性能是可選的
以前已經說明了 Julia 提供了多種方法來提高性能(好比 @inbounds),但咱們不必定要使用它們。你也能夠編寫類型不穩定的函數,雖然與 MATLAB、R 語言、Python 同樣慢,但你絕對能夠這麼作。在對性能要求沒有那麼高的地方,能夠將其做爲一個可選項。
檢查類型穩定性
因爲類型穩定性很是重要,Julia 爲咱們提供了一些工具,用來檢查一個函數是否是類型穩定的,其中最重要的是 @code_warntype 宏。讓咱們用它來檢查一個類型穩定的函數:
@code_warntype 2^5
Body::Int64
│220 1 ─ %1 = invoke Base.power_by_squaring(_2::Int64, _3::Int64)::Int64
│ └── return %1
請注意,它將函數中全部變量都顯示爲嚴格類型。那麼 expo 會是怎樣的?
@code_warntype expo(2,5)
Body::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
32
在第 2 行,x 被分配了一個 Int,而在第 5 行又被分配了一個 Float64,所以它被推斷爲 Union{Float64, Int64}。第 5 行是咱們放置顯式轉換調用的地方,這樣咱們就肯定了問題所在的位置。
處理必要的類型不穩定性
首先,我已經證實了某些在 Julia 會出錯的函數在其餘腳本語言中卻能夠「讀懂你的想法」。在不少狀況下,你會發現你能夠從一開始就使用不一樣的類型,以此來實現類型穩定性(爲何不直接使用 2.0^-5?)。可是,在某些狀況下,你找不到合適的類型。這個問題能夠經過轉換來解決,但這樣會失去類型穩定性。你必須從新考慮你的設計,並巧妙地使用多重分派。
假設咱們有一個 Vector{Union{Float64,Int}}類型的 a,而且可能遇到必須使用 a 的狀況,須要在 a 的每一個元素上執行大量操做。在這種狀況下,知道給定元素的類型將帶來性能的大幅提高,但因爲類型位於 Vector{Union{Float64,Int}}中,所以沒法在下面這樣的函數中識別出類型:
function foo(array)
for i in eachindex(array)
val = array[i]
# do algorithm X on val
end
end
foo (generic function with 1 method)
不過,咱們能夠經過多重分派來解決這個問題。咱們能夠在元素上使用分派:
function inner_foo(val)
# Do algorithm X on val
end
inner_foo (generic function with 1 method)
而後將 foo 定義爲:
function foo2(array::Array)
for i in eachindex(array)
inner_foo(array[i])
end
end
foo2 (generic function with 1 method)
由於須要爲分派檢查類型,因此 inner_foo 函數是嚴格類型化的。所以,若是 inner_foo 是類型穩定的,那麼就能夠經過專門化 inner_foo 來提升性能。這就致使了一個通用的設計原則:在處理奇怪或非嚴格的類型時,可使用一個外部函數來處理邏輯類型,同時使用一個內部函數來處理計算任務,實現最佳的性能,同時仍然具有腳本語言的通用能力。
REPL 的全局做用域性能很糟糕
Julia 全局做用域的性能很糟糕。官方的性能指南建議不要使用全局做用域。然而,新手可能會意識不到 REPL 其實就是全局做用域。爲何?首先,Julia 是有嵌套做用域的。例如,若是函數內部有函數,那麼內部函數就能夠訪問外部函數的全部變量。
function test(x)
y = x+2
function test2()
y+3
end
test2()
end
test (generic function with 1 method)
在 test2 中,y 是已知的,由於它是在 test 中定義的。若是 y 是類型穩定的,那麼全部這些工做就能夠帶來性能的提高,由於 test2 能夠假設 y 是一個整數。如今讓咱們來看一下在全局做用域裏會發生什麼:
a = 3
function badidea()
a + 2
end
a = 3.0
3.0
由於沒有使用分派來專門化 badidea,而且能夠隨時更改 a 的類型,所以 badidea 在編譯時沒法進行優化,由於在編譯期間 a 的類型是未知的。可是,Julia 容許咱們聲明常量:
const a_cons = 3
function badidea()
a_cons + 2
end
badidea (generic function with 1 method)
請注意,函數將使用常量的值來進行專門化,所以它們在設置後應該保持不變。
在進行基準測試時會出現這種狀況。新手會像下面這樣對 Julia 進行基準測試:
a = 3.0
@time for i = 1:4
global a
a += i
end
0.000006 seconds (4 allocations: 64 bytes)
可是,若是咱們將它放在一個函數中,就能夠實現優化。
function timetest()
a = 3.0
@time for i = 1:4
a += i
end
end
timetest() # First time compiles
timetest()
0.000001 seconds
0.000000 seconds
這個問題很是容易解決:不要在 REPL 的全局做用域內進行基準測試或計算執行時間。始終將代碼放在函數中,或將它們聲明爲 const。
結 論
速度是 Julia 的設計目標。類型穩定性和多重分派對 Julia 編譯的專門化起到了關鍵的做用。而要達到如此精細的類型處理水平,以便儘量有效地實現類型穩定性,並在不徹底可能的狀況下實現性能優化,須要一個健壯的類型系統。