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

2021-01-11 機器之心Pro

選自Github

機器之心編譯

參與:思源、李亞洲

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

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

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

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

為什麼其他語言不能更快一點?Julia 能夠做到,其他語言就不能?你怎麼解釋 Julia 的速度基準?(對許多其他語言來說也很難?)這聽起來違背沒有免費午餐定律,在其他方面是否有損失?

許多人認為 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.5f(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^5output: 322^-5output: 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>0return x^yelse x = convert(Float64,x)return x^y endendoutput: 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] = :Symbolicaoutput: 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/4aoutput: 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│2201 ─ %1 = invoke Base.power_by_squaring(_2::Int64, _3::Int64)::Int64│ └── return %1

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

@code_warntype 2^5Body::Union{Float64, Int64}│ >21 ─ %1 = (Base.slt_int)(0, y)::Bool│ └── goto #3 if not %1│ 32 ─ %3 = π (x, Int64)│ ^ │ %4 = invoke Base.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:

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]:2output: 32

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

結 論

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

原文連結:https://ucidatascienceinitiative.github.io/IntroToJulia/Html/WhyJulia

2018明星學術公眾號TOP10重磅發布,機器之心再次上榜

相關焦點

  • 如何讓Python像Julia一樣快地運行
    我不會責怪 Julia 團隊,因為我很內疚自己也有同樣的偏見。但我受到了殘酷的教訓:付出任何代價都要避免數組或列表上的循環,因為它們確實會拖慢 Python中的速度,請參閱《Python 不是 C》。 考慮到對 C 風格的這種偏見,一個有趣的問題(至少對我而言)是,我們能否改進這些基準測試,更好地使用 Python 及其工具?
  • Julia程式語言助力天氣/氣候模式
    原文摘要Posit數制號稱在許多領域的應用中具有更小的算術捨入誤差,成為了替代浮點數制的一種方案。通過研究天氣和氣候領域中低複雜度的模式,譬如,Lorenz系統和淺水模型,相比於傳統的float16數制能夠取得良好的效果。儘管一款標準化的數制處理器並不存在,但是我們可以利用傳統的CPU來模擬數制算法。
  • 如何使用 Julia 語言實現「同態加密+機器學習」?
    現在陸續出現了一些更新也更實際的 FHE 方案。更重要的是,還有一些可以高效地實現這一方案的軟體包。最常用的兩個軟體包是 Microsoft SEAL和 PALISADE。此外,我最近還開源了這些算法的 Julia 實現(https://github.com/JuliaComputing/ToyFHE.jl)。出於我們的目的,我們將使用後者中實現的 CKKS 加密。
  • Python能幹什麼?為什麼會這麼火
    為什麼會這麼火?那麼Python能幹什麼呢?1.網站後端程式設計師:使用它單間網站,後臺服務比較容易維護。Python課程為什麼會這麼火呢?因為Python簡單明了,非常容易上手。對於新手或者初學者來說,Python是非常容易學習和使用的,最容易學習的程式語言之一。部分原因是因為它簡化了語法,更加貼近於自然語言,可以讓Python代碼更加快速的執行。
  • python是什麼,python能幹什麼,為什麼大家都學pyhon一起來看看吧
    首先大家要明白python是一種跨平臺的程式語言,python編程的特點易讀、易維護,所以被大量的用戶所歡迎,python最大的特點是開發速度快,因為編程開發效率一直很低,python有很多第三方庫,所以開發起來事半功倍,很流行的一句話,人生苦短,我學python,可謂是把python特點完整的詮釋出來。
  • 廣州Python測試培訓
    具有 簡單、易學、開源、可移植、可擴展、可嵌入、面向對象 等 優點,它的面向對象甚至比java和C#.net更徹底。
  • Python為什麼這麼火?小孩子適合學習python編程嗎?
    YouTube、Instagram、豆瓣、知乎、果殼等都是用python寫的,意不意外?驚不驚喜?除了C端應用之外,Python還有著最為成熟的程序包資源庫之一PyPI,這個資源庫包含著超過85000個腳本資源與模塊,上手就能用,同時python具有獨特的開源且跨平臺特性,不管是windows、macOS還是Linux,Python都可以輕鬆運行,配置環境過程也無比簡單。
  • 三問Python:能幹什麼?為什麼火?會繼續火嗎?
    但python的就業呢?自己就還沒了解清楚了。秋招的時候 Python 的崗位確實不多,尤其是像 BAT 這樣的一線公司,基本上 Python 崗位都是運維開發和測試開發。二三線網際網路還是有不少 Python 崗位的,例如餓了麼、愛奇藝、頭條等,而且因為投的人不多,所以競爭力比較小。
  • 相比於Java,python到底有哪些優勢?
    由於在AI的帶動下python更是異軍突起,撼動了許多老大哥的地位。可唯獨java穩如泰山,不可動搖!自然而然的就會出現python與Java的討論聲。本文的目的在於討論python和java相比到底有哪些優勢,至於缺點暫且不提!
  • 5個原因告訴你,為什麼說Julia比Python要好?
    這對包擴展是一個很大的好處,因為無論何時顯示導入方法,用戶都可以更改它。顯式導入方法並將其擴展為將結構路由到新函數會很容易。3、速度談到Julia不談速度是很難的。Julia以速度快而自豪。Julia與Python不同,Python是一種編譯語言,它主要是用自己的基礎編寫的。然而,與C等其他編譯語言不同,Julia是在運行時編譯的,而傳統語言是在執行之前編譯的。
  • 天津python學習費用多少
    對新鮮的Python體系一網打盡 誠築說課程學的編程技術幾乎囊獲了99%國內公司可以用到的,擴展技術讓你成為室內設計全能神 為什麼誠築說的課程一學就會
  • 《小灰教你零基礎學python》-Python入門語言
    電腦(包括手機)由硬體和程序構成:很多硬體 + 很多程序 = 電腦具體硬體和程序如何集成這個咱們不用太了解,這個是計算機設計原理裡面的東西了,咱只需要了解,電腦就是硬體(攝像頭、鍵盤、滑鼠、電源啊等等程式語言有很多,咱們就學簡單強大的python即可。
  • 為什麼 Biopython 的在線 BLAST 這麼慢?
    這到底是為什麼呢?NCBIWWW 基本用法首先,我們來看一下提供了基於 API 在線比對的 Biopython 模塊。默認值為 「XML」,因為這是解析器期望的格式。參數 expect 用於設置期望值或 e-value 閾值。有關可選的 BLAST 參數的更多信息,請參考 NCBI 自己的文檔或 Biopython 內置的文檔:請注意,NCBI BLAST 網站上的默認設置與 qblast 上的默認設置不太相同。
  • 成都學習Python開發哪家好
    如何選擇成都python培訓機構? python程式語言語法清晰、乾淨、易讀、易維護、代碼量小、可讀性強。當團隊合作開發時,閱讀別人的代碼將是非常迅速和高效的。通俗說來就是「寫起來快、看起來明白!」所以近年來,python開發非常流行。
  • 成都Python培訓周期多久
    成都python培訓哪個更專業? Python的設計目標之一是使代碼具有很高的可讀性。它被設計成使用標點符號和其他語言中常用的英語單詞,使代碼看起來整潔美觀。現在成都有很多python培訓學校。
  • |python|電氣和電子工程師協會|編程...
    看到這條微博,我很意外,一個年過半百的房地產大佬,怎麼想都和python挨不著邊啊。直到我發現,早在這之前,「中國比特幣首富」李笑來就已經帶著登頂 GitHub 的Python項目,「殺」回了大眾視野。
  • 動物們為什麼瘋狂奔跑,因為無聊啊!
    在其之前我從未將這三個詞聯繫在一起,但是這幾天,收到廣西師大旗下的魔法象品牌出版的《跑啊》這本繪這以後,我被這本非常意識流的繪本整蒙圈了。《跑啊!》是韓國極具影響力的童書作家之一李惠利的作品,她擅長用輕鬆的畫面表現深刻的主題,其作品能讓人在幽默風趣的故事中學會思考,因而深受家長和孩子的喜愛。《跑啊!》
  • 搭上python號小火箭,程序運行越來越快!
    其實,無論使用哪種程式語言,特定程序的運行速度很大程度上都取決於該程序的開發人員及其編寫快而優的程序的技巧和能力。語言方面的問題我們解決不了,所以只能在編程技巧上來提高程序的運行效率。是時候證明給那些python黑粉,讓他們看看如何提升Python程序性能並使其像坐上火箭一樣運行飛快!
  • Python 拓展之詳解深拷貝和淺拷貝
    一是因為 copy 這個方法比較特殊,不單單是它表面的意思;二是以為昨天的文章寫得比較長,可能你看到那的時候就沒啥耐心去仔細思考了,但是這個知識點又比較重要,也是面試過程中會被長問起的題,我之前在面試的時候(乾貨滿滿--親身經歷的 Python 面試題)就被問起過。所以我把 copy 單獨摘出來今天單講。
  • Python2 已終結,入手Python 3,你需要這30個技巧
    檢查你的對象佔用了多少內存你可以使用 sys.getsizeof() 來查看你創建的對象佔用的內存大小:哇,等一下,為什麼這麼大的 list 只有 48 字節?這是因為 range 函數隻返回了一個類似 list 的類。