Julia和Python的關鍵區別
你知道為什么Julia的速度能做到那么快嗎?這并不是因為更好的編譯器,而是一種更新的設計理念,Julia在開發之初就將這種理念納入其中,
短短幾年,由MITCSAIL實驗室開發的編程語言Julia已然成為編程界的新寵,尤其在科學計算領域炙手可熱。很大部分是因為這門語言結合了C語言的速度、Ruby的靈活、Python的通用性,以及其他各種語言的優勢于一身。
Julia因為它比其他腳本語言更快,它在具備Python、MATLAB、R語言開發速度的同時,又能生成與C語言和Fortran一樣快的代碼。
Python市場
全世界有超過800萬的開發人員出于各種目的熱忠于使用Python。由于其動態特性和易于擴展性,Python已經成為開發人員的首選語言。這也是為什么Python能夠擊敗Java的原因,Java一度以來都是開發人員最喜歡的語言。也可能是由于一門語言的自然老化過程,Java正在接近尾聲。大多數新語言都是為解決現代面臨的新挑戰而設計的。雖然之前開發的語言在解決當時的問題時效率極高,但要讓它們跟上不斷變化的行業和市場就變得極其困難。
但是,Python作為一種擁有如此龐大用戶和開發者支持的開源語言,即使在今天仍然保持著它的巔峰狀態。它豐富的庫和內置的功能使其成為企業、開發人員和數據科學家的熱門選擇。盡管Java仍然被用于企業開發,但它在其他領域的相關性幾乎為零。如果環顧四周,你很難發現一個機器學習專家在Java上設計和訓練模型。盡管如此,Java是全球第二大最受開發人員歡迎的語言。
取代Java
Python已經成功地在大多數領域取代了Java。在企業開發方面,Java面臨著來自谷歌的新編程語言Go的威脅。隨著我們進入未來科技時代,對高性能計算的需求也在不斷增長。這也是數據科學和人工智能的時代需求。盡管有人可能認為使用extremeGPU有助于提高速度和效率,但事實遠非如此。它不能滿足特定的數據處理需求。相反,前沿應用程序需要其他依賴項來優化性能,并幫助科學家和開發人員實現預期的目標。最終,這將引導企業和研究機構尋找更健壯的編程語言,為特定的任務及其交付速度而設計。
進入Julia的世界
這個人人都喜愛Python的時代,正面臨著來自編程語言世界的新參與者——Julia的威脅。ViralShah是JuliaComputing的首席執行官,他指出,在21世紀初,開發人員更喜歡用C語言進行系統編程,用JAVA開發企業應用程序,用SaaS進行分析,用MATLAB進行科學計算。然而,今天的開發人員使用Rust進行系統編程,Go進行企業開發,使用Python/R進行分析,并使用Julia進行科學計算。
Julia立足之地
目前有超過800名Julia開發人員,他們正在為GitHub做貢獻,幫助其成為首選語言。
Julia基準測試來證明它的速度
這似乎有違“天底下沒有免費的午餐”的道理。它真的有那么完美嗎?
很多人認為Julia運行速度很快,因為它是即時編譯(JIT)型的(也就是說,每條語句都使用編譯的函數來運行,這些函數要么在使用之前進行即時編譯,要么在之前已經編譯過并放在緩存中)。這就引出了一個問題:Julia是否提供了比Python或R語言(MATLAB默認使用JIT)更好的JIT實現?因為人們在這些JIT編譯器上所做的工作比Julia要多得多,所以我們憑什么認為Julia這么快就會超過這些編譯器?但其實這完全是對Julia的誤解。
Julia的的核心設計決策是通過多重分派實現專門化的類型穩定性,編譯器因此可以很容易地生成高效的代碼,同時還能夠保持代碼的簡潔,讓它“看起來就像一門腳本語言”。
但是,在本文的示例中,我們將看到Julia并不總是像其他腳本語言那樣,我們必須接受“午餐不全是免費”的事實。
要看出它們之間的區別,我們只需要看看基本的數學運算。
Julia中的數學運算
一般來說,Julia中的數學運算與其他腳本語言中的數學運算看起來是一樣的。它們的數字都是“真正的數字”,比如Float64就是64位浮點數或者類似于C語言中的“double”。Vector{Float64}與C語言double數組的內存布局是一樣的,都可以很容易地與C語言進行互操作(實際上,在某種意義上,“Julia是構建在C語言之上的一個層”),從而帶來更高的性能。
a=2+2
b=a/3
c=a÷3#divtabcompletion,meansintegerdivision
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_llvm2*5
;Function*
;Location:int.jl:54
definei64@"julia_*_33751"(i64,i64){
top:
%2=muli64%1,%0
reti64%2
}
@code_native2*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
;}
現在,我們來定義自己的整數乘方運算。與其他腳本語言一樣,我們讓它變得更“安全”:
functionexpo(x,y)
ify>0
returnx^y
else
x=convert(Float64,x)
returnx^y
end
end
expo(genericfunctionwith3method)
現在運行一下看看行不行:
println(expo(2,5))
expo(2,-5)
32
0.03125
再來看看匯編代碼。
@code_nativeexpo(2,5)
.text
;Functionexpo{
;Location:In[8]:2
pushq%rbx
movq%rdi,%rbx
;Function>;{
;Location:operators.jl:286
;Function<;{
;Location:int.jl:49
testq%rdx,%rdx
;}}
jleL36
;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
;Functionconvert;{
;Location:number.jl:7
;FunctionType;{
;Location:float.jl:60
L36:
vcvtsi2sdq%rsi,%xmm0,%xmm0
;}}
;Location:In[8]:6
;Function^;{
;Location:math.jl:780
;FunctionType;{
;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的核心思想,現在讓我們花點時間深入了解一下。
如果函數內部具有類型穩定性(也就是說,函數內的任意函數調用也是類型穩定的),那么編譯器就會知道每一步的變量類型,它就可以在編譯函數時進行充分的優化,這樣得到的代碼基本上與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默認情況下也會這么做。
functiontest1()
a=zeros(3)
fori=1:4
a[i]=i
end
end
test1()
BoundsError:attempttoaccess3-elementArray{Float64,1}atindex[4]
Stacktrace:
[1]setindex!at./array.jl:769[inlined]
[2]test1()at./In[11]:4
[3]top-levelscopeatIn[11]:7
不過,你可以使用@inbounds宏來禁用這個功能:
functiontest2()
a=zeros(3)
@inboundsfori=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-elementArray{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-elementArray{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_warntype2^5
Body::Int64
│2201─%1=invokeBase.power_by_squaring(_2::Int64,_3::Int64)::Int64
│└──return%1
請注意,它將函數中所有變量都顯示為嚴格類型。那么expo會是怎樣的?
@code_warntypeexpo(2,5)
Body::Union{Float64,Int64}
│??>21─%1=(Base.slt_int)(0,y)::Bool
│└──goto#3ifnot%1
│32─%3=π(x,Int64)
│?^│%4=invokeBase.power_by_squaring(%3::Int64,_3::Int64)::Int64
│└──return%4
│53─%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:
usingTraceur
@traceexpo(2,5)
┌Warning:xisassignedasInt64
└@In[8]:2
┌Warning:xisassignedasFloat64
└@In[8]:5
┌Warning:exporeturnsUnion{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}}中,因此無法在下面這樣的函數中識別出類型:
functionfoo(array)
foriineachindex(array)
val=array[i]
#doalgorithmXonval
end
end
foo(genericfunctionwith3method)
不過,我們可以通過多重分派來解決這個問題。我們可以在元素上使用分派:
functioninner_foo(val)
#DoalgorithmXonval
end
inner_foo(genericfunctionwith3method)
然后將foo定義為:
functionfoo2(array::Array)
foriineachindex(array)
inner_foo(array[i])
end
end
foo2(genericfunctionwith3method)
因為需要為分派檢查類型,所以inner_foo函數是嚴格類型化的。因此,如果inner_foo是類型穩定的,那么就可以通過專門化inner_foo來提高性能。這就導致了一個通用的設計原則:在處理奇怪或非嚴格的類型時,可以使用一個外部函數來處理邏輯類型,同時使用一個內部函數來處理計算任務,實現最佳的性能,同時仍然具備腳本語言的通用能力。
Julia全局作用域的性能很糟糕。官方的性能指南建議不要使用全局作用域。然而,新手可能會意識不到REPL其實就是全局作用域。為什么?首先,Julia是有嵌套作用域的。例如,如果函數內部有函數,那么內部函數就可以訪問外部函數的所有變量。
functiontest(x)
y=x+2
functiontest2()
y+3
end
test2()
end
test(genericfunctionwith3method)
在test2中,y是已知的,因為它是在test中定義的。如果y是類型穩定的,那么所有這些工作就可以帶來性能的提升,因為test2可以假設y是一個整數。現在讓我們來看一下在全局作用域里會發生什么:
a=3
functionbadidea()
a+2
end
a=3.0
3.0
因為沒有使用分派來專門化badidea,并且可以隨時更改a的類型,因此badidea在編譯時無法進行優化,因為在編譯期間a的類型是未知的。但是,Julia允許我們聲明常量:
consta_cons=3
functionbadidea()
a_cons+2
end
badidea(genericfunctionwith3method)
請注意,函數將使用常量的值來進行專門化,因此它們在設置后應該保持不變。
在進行基準測試時會出現這種情況。新手會像下面這樣對Julia進行基準測試:
a=3.0
@timefori=1:4
globala
a+=i
end
0.000006seconds(4allocations:64bytes)
但是,如果我們將它放在一個函數中,就可以實現優化。
functiontimetest()
a=3.0
@timefori=1:4
a+=i
end
end
timetest()#Firsttimecompiles
timetest()
0.000001seconds
0.000000seconds
這個問題非常容易解決:不要在REPL的全局作用域內進行基準測試或計算執行時間。始終將代碼放在函數中,或將它們聲明為const。
結論
速度是Julia的設計目標。類型穩定性和多重分派對Julia編譯的專門化起到了關鍵的作用。而要達到如此精細的類型處理水平,以便盡可能有效地實現類型穩定性,并在不完全可能的情況下實現性能優化,需要一個健壯的類型系統。
盡管現在很難說它能否完全接管Python,但它設計用于處理復雜的計算特性肯定會對世界產生影響。此外,隨著問題的處理需要更多的資源和更高性能的計算,Julia可能會成為每個人的最愛。除非Python想要和Java一樣的命運,否則它將不得不提高其速度和效率,并不斷優化它的庫。它可能不只是啟動新的更新,而是完全轉換引擎,使其成為更友好的CPU語言。Python相對于Julia的一個優勢是其豐富的庫。由于Julia還處于起步階段,所以它需要很長時間才能構建像Python這樣高效、動態的庫和函數。這兩種語言之間的斗爭才剛剛開始,但對于需要快速高效工具來實現目標的研究人員和科學家來說,Julia已經變成了一種優勢。