效能提示

在以下各節中,我們將簡要介紹一些技術,這些技術有助於讓您的 Julia 程式碼執行得盡可能快。

效能重要的程式碼應該在函式內

任何效能重要的程式碼都應該在函式內。由於 Julia 編譯器的工作方式,函式內的程式碼往往執行得比頂層程式碼快很多。

使用函式不僅對效能很重要:函式更具可重複使用性和可測試性,並釐清執行的步驟以及其輸入和輸出,撰寫函式,而不要只寫指令碼也是 Julia 風格指南的建議。

函式應採用引數,而不是直接對全域變數進行操作,請參閱下一點。

避免未輸入型別的全域變數

未輸入型別的全域變數的值可能會在任何時候變更,可能會導致其型別變更。這使得編譯器難以最佳化使用全域變數的程式碼。這也適用於型別值變數,也就是全域層級的型別別名。變數應為區域變數,或在可能的情況下傳遞為函式的引數。

我們發現全域名稱通常是常數,將它們宣告為常數會大幅提升效能

const DEFAULT_VAL = 0

如果已知全域變數的型別始終相同,應註解型別

未設定型別的全域變數可以使用在使用點註解型別進行最佳化

global x = rand(1000)

function loop_over_global()
    s = 0.0
    for i in x::Vector{Float64}
        s += i
    end
    return s
end

將引數傳遞給函式是較佳的風格。這會產生更多可重複使用的程式碼,並釐清輸入和輸出為何。

注意

REPL 中的所有程式碼都評估在全域範圍,因此在頂層定義和指定變數會是全域變數。在模組內頂層範圍定義的變數也是全域變數。

在下列 REPL 會話中

julia> x = 1.0

等於

julia> global x = 1.0

因此先前討論的所有效能問題都適用。

使用 @time 測量效能並注意記憶體配置

測量效能的實用工具為 @time 巨集。我們在此重複上述全域變數範例,但這次移除型別註解

julia> x = rand(1000);

julia> function sum_global()
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_global()
  0.011539 seconds (9.08 k allocations: 373.386 KiB, 98.69% compilation time)
523.0007221951678

julia> @time sum_global()
  0.000091 seconds (3.49 k allocations: 70.156 KiB)
523.0007221951678

在第一次呼叫 (@time sum_global()) 時,函式會編譯。(如果您在此階段尚未使用 @time,它也會編譯計時所需函式。)您不應認真看待此執行結果。對於第二次執行,請注意,除了報告時間外,它還指出已配置大量記憶體。我們在此僅計算 64 位元浮點數向量中所有元素的總和,因此不應需要配置(堆疊)記憶體。

我們應釐清 @time 報告的內容,特別是堆疊配置,通常需要用於可變物件或建立/增加可變大小容器(例如 ArrayDict、字串或「型別不穩定」物件,其型別僅在執行階段得知)。配置(或解除配置)此類記憶體區塊可能需要昂貴的系統呼叫(例如透過 C 中的 malloc),且必須追蹤以進行垃圾回收。相反地,不可變值(例如數字、元組和不可變 struct)可以便宜許多地儲存在堆疊或 CPU 暫存器記憶體中,因此通常不必擔心「配置」它們的效能成本。

意外的記憶體配置幾乎總是表示您的程式碼出現問題,通常是型別穩定性問題或建立許多小型暫時陣列的問題。因此,除了配置本身外,為您的函式產生的程式碼很可能遠非最佳。請認真看待此類指示,並遵循以下建議。

在此特定情況下,記憶體配置是因使用型別不穩定的全域變數 x 所致,因此如果我們將 x 傳遞為函式的引數,它就不會再配置記憶體(下方報告的剩餘配置是因在全域範圍內執行 @time 巨集所致),且在第一次呼叫後會顯著加快。

julia> x = rand(1000);

julia> function sum_arg(x)
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_arg(x)
  0.007551 seconds (3.98 k allocations: 200.548 KiB, 99.77% compilation time)
523.0007221951678

julia> @time sum_arg(x)
  0.000006 seconds (1 allocation: 16 bytes)
523.0007221951678

所見的 1 個配置來自於在全域範圍內執行 @time 巨集本身。如果我們改在函式中執行計時,我們可以看到確實未執行任何配置。

julia> time_sum(x) = @time sum_arg(x);

julia> time_sum(x)
  0.000002 seconds
523.0007221951678

在某些情況下,你的函數可能需要在運作中配置記憶體,這可能會使上述的簡單畫面變得複雜。在這種情況下,請考慮使用以下 工具 之一來診斷問題,或撰寫一個將配置與演算法面向分開的函數版本(請參閱 預先配置輸出)。

注意

對於更嚴謹的基準測試,請考慮 BenchmarkTools.jl 套件,它會在其他事項中,多次評估函數以減少雜訊。

工具

Julia 及其套件生態系統包含可能有助於你診斷問題並改善程式碼效能的工具

  • 剖析 讓你能夠測量正在執行的程式碼效能,並找出作為瓶頸的行。對於複雜的專案,ProfileView 套件可以協助你將剖析結果視覺化。
  • Traceur 套件可以協助你找出程式碼中常見的效能問題。
  • 意外過大的記憶體配置(如 @time@allocated 或剖析器(透過呼叫垃圾收集常式)所報告的)暗示你的程式碼可能存在問題。如果你沒有看到配置的其他原因,請懷疑類型問題。你也可以使用 --track-allocation=user 選項啟動 Julia,並檢查產生的 *.mem 檔案,以查看這些配置發生在哪裡的資訊。請參閱 記憶體配置分析
  • @code_warntype 會產生程式碼的表示,這有助於找出導致類型不確定性的表達式。請參閱以下 @code_warntype

避免使用具有抽象類型參數的容器

在使用參數化類型(包含陣列)時,最好避免使用抽象類型參數化。

考慮以下範例

julia> a = Real[]
Real[]

julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
 1
 2.0
 π = 3.1415926535897...

由於 a 是抽象類型 Real 的陣列,因此它必須能夠儲存任何 Real 值。由於 Real 物件可以是任意大小和結構,因此 a 必須表示為個別配置的 Real 物件指標陣列。但是,如果我們只允許相同類型的數字(例如 Float64)儲存在 a 中,則可以更有效率地儲存這些數字

julia> a = Float64[]
Float64[]

julia> push!(a, 1); push!(a, 2.0); push!(a,  π)
3-element Vector{Float64}:
 1.0
 2.0
 3.141592653589793

將數字指定給 a 現在會將它們轉換為 Float64,而 a 將儲存為可以有效率地操作的 64 位元浮點值連續區塊。

如果您無法避免使用具有抽象值類型的容器,有時最好使用 Any 參數化以避免執行時期類型檢查。例如,IdDict{Any, Any} 的效能優於 IdDict{Type, Vector}

請參閱 參數化類型 中的討論。

類型宣告

在許多具有可選類型宣告的語言中,新增宣告是讓程式碼執行得更快的主要方式。在 Julia 中並非如此。在 Julia 中,編譯器通常知道所有函式引數、局部變數和表達式的類型。但是,有幾個特定範例宣告是有幫助的。

避免使用具有抽象類型的欄位

類型可以在未指定其欄位類型的情況下宣告

julia> struct MyAmbiguousType
           a
       end

這允許 a 為任何類型。這通常很有用,但它確實有一個缺點:對於類型為 MyAmbiguousType 的物件,編譯器將無法產生高性能的程式碼。原因是編譯器使用物件的類型,而非其值,來決定如何建立程式碼。不幸的是,對於類型為 MyAmbiguousType 的物件,幾乎無法推論出任何資訊

julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")

julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

bc 的值具有相同的類型,但它們在記憶體中資料的底層表示卻非常不同。即使您僅在欄位 a 中儲存數字值,UInt8 的記憶體表示與 Float64 不同的事實也表示 CPU 需要使用兩種不同的指令來處理它們。由於類型中沒有可用的必要資訊,因此此類決策必須在執行時做出。這會降低效能。

您可以透過宣告 a 的類型來改善。在此,我們專注於 a 可能為多種類型之一的情況,在這種情況下,自然解法是使用參數。例如

julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

這是比

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

更好的選擇,因為第一個版本從包裝物件的類型指定 a 的類型。例如

julia> m = MyType(3.2)
MyType{Float64}(3.2)

julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

欄位 a 的類型可以從 m 的類型輕鬆地確定,但無法從 t 的類型確定。事實上,在 t 中,可以變更欄位 a 的類型

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

相反地,一旦建構 mm.a 的類型就無法變更

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

m 的類型得知 m.a 的類型,加上其類型在函式中段無法變更,編譯器就能為 m 等物件產生高度最佳化的程式碼,但無法為 t 等物件產生程式碼。

當然,所有這些情況都只在我們以具體類型建構 m 時成立。我們可以透過明確以抽象類型建構它來打破這個限制

julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

在所有實際用途上,此類物件的行為與 MyStillAmbiguousType 的物件相同。

比較為簡單函式產生的程式碼量非常具有啟發性

func(m::MyType) = m.a+1

使用

code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})

由於長度因素,結果在此未顯示,但你可以自行嘗試。由於第一個案例中類型已完全指定,編譯器不需要產生任何程式碼來在執行階段解析類型。這會產生更短且更快的程式碼。

還應記住,未完全參數化的類型會像抽象類型一樣運作。例如,即使完全指定的 Array{T,n} 是具體的,但未提供任何參數的 Array 本身並非具體的

julia> !isconcretetype(Array), !isabstracttype(Array), isstructtype(Array), !isconcretetype(Array{Int}), isconcretetype(Array{Int,1})
(true, true, true, true, true)

在這種情況下,最好避免宣告 MyType 時使用欄位 a::Array,而應將欄位宣告為 a::Array{T,N}a::A,其中 {T,N}AMyType 的參數。

避免具有抽象容器的欄位

相同的最佳實務也適用於容器類型

julia> struct MySimpleContainer{A<:AbstractVector}
           a::A
       end

julia> struct MyAmbiguousContainer{T}
           a::AbstractVector{T}
       end

julia> struct MyAlsoAmbiguousContainer
           a::Array
       end

例如

julia> c = MySimpleContainer(1:3);

julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}

julia> c = MySimpleContainer([1:3;]);

julia> typeof(c)
MySimpleContainer{Vector{Int64}}

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> b = MyAmbiguousContainer([1:3;]);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> d = MyAlsoAmbiguousContainer(1:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Int64})

julia> d = MyAlsoAmbiguousContainer(1:1.0:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Float64})

對於 MySimpleContainer,物件會由其類型和參數完全指定,因此編譯器可以產生最佳化的函式。在大部分情況下,這可能就夠了。

雖然編譯器現在可以完美地執行其工作,但有時你可能會希望你的程式碼可以根據 a元素類型執行不同的動作。通常達成此目的的最佳方式是將你的特定運算 (在此為 foo) 包裝在一個獨立的函式中

julia> function sumfoo(c::MySimpleContainer)
           s = 0
           for x in c.a
               s += foo(x)
           end
           s
       end
sumfoo (generic function with 1 method)

julia> foo(x::Integer) = x
foo (generic function with 1 method)

julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)

這讓事情變得簡單,同時允許編譯器在所有情況下產生最佳化的程式碼。

然而,在某些情況下,您可能需要為不同的元素類型或 MySimpleContainer 中欄位 aAbstractVector 類型宣告不同版本的外部函數。您可以像這樣做

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
           return c.a[1]+1
       end
myfunc (generic function with 1 method)

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
           return c.a[1]+2
       end
myfunc (generic function with 2 methods)

julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
           return c.a[1]+3
       end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3))
2

julia> myfunc(MySimpleContainer(1.0:3))
3.0

julia> myfunc(MySimpleContainer([1:3;]))
4

註解取自非類型化位置的值

使用可能包含任何類型值的資料結構(類型為 Array{Any} 的陣列)通常很方便。但是,如果您使用其中一個結構並碰巧知道元素的類型,這有助於與編譯器分享此知識

function foo(a::Array{Any,1})
    x = a[1]::Int32
    b = x+1
    ...
end

在這裡,我們碰巧知道 a 的第一個元素將會是 Int32。像這樣進行註解具有額外的優點,如果值不是預期的類型,它將引發執行時期錯誤,可能會更早地捕捉到某些錯誤。

如果 a[1] 的類型無法精確得知,則可以透過 x = convert(Int32, a[1])::Int32 來宣告 x。使用 convert 函數允許 a[1] 成為任何可轉換為 Int32 的物件(例如 UInt8),從而透過放寬類型需求來增加程式碼的通用性。請注意,在這種情況下,convert 本身需要類型註解才能達到類型穩定性。這是因為編譯器無法推論函數的傳回值類型,即使是 convert,除非已知所有函數參數的類型。

如果類型是抽象的或在執行時期建構,類型註解不會增強(實際上可能會阻礙)效能。這是因為編譯器無法使用註解來專門化後續程式碼,而且類型檢查本身需要時間。例如,在程式碼中

function nr(a, prec)
    ctype = prec == 32 ? Float32 : Float64
    b = Complex{ctype}(a)
    c = (b + 1.0f0)::Complex{ctype}
    abs(c)
end

c 的註解會損害效能。若要撰寫涉及在執行期間建構的型別的高效能程式碼,請使用下方討論的 函式障礙技術,並確保建構的型別出現在核心函式的引數型別中,以便編譯器適當地對核心運算進行最佳化。例如,在上述程式片段中,一旦建構 b,便可以將它傳遞給另一個函式 k,也就是核心。如果函式 kb 宣告為型別 Complex{T} 的引數,其中 T 是型別參數,則出現在 k 內部指派陳述式中的型別註解,形式為

c = (b + 1.0f0)::Complex{T}

不會妨礙效能(但也不會有所幫助),因為編譯器可以在編譯 k 時決定 c 的型別。

注意 Julia 何時避免最佳化

根據經驗法則,Julia 會在三種特定情況下避免自動對引數型別參數進行 最佳化TypeFunctionVararg。如果引數在方法內部使用,Julia 將永遠進行最佳化,但如果引數僅傳遞到另一個函式,則不會。這通常不會對執行期間的效能產生影響,而且 會提升編譯器效能。如果您發現這會對您案例中的執行期間效能產生影響,您可以透過在方法宣告中新增型別參數來觸發最佳化。以下是一些範例

這不會進行最佳化

function f_type(t)  # or t::Type
    x = ones(t, 10)
    return sum(map(sin, x))
end

但這會進行最佳化

function g_type(t::Type{T}) where T
    x = ones(T, 10)
    return sum(map(sin, x))
end

這些不會進行最佳化

f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))

但這會進行最佳化

h_func(h::H, num) where {H} = ntuple(h, div(num, 2))

這不會進行最佳化

f_vararg(x::Int...) = tuple(x...)

但這會進行最佳化

g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)

即使其他型別沒有限制,也只需引入一個型別參數即可強制進行最佳化。例如,這也會進行最佳化,而且在引數並非全部為相同型別時很有用

h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)

請注意,@code_typed 和相關函式將永遠顯示特定的程式碼,即使 Julia 通常不會對該方法呼叫進行特定化。如果您想查看在變更引數類型時是否會產生特定化,也就是說,如果 Base.specializations(@which f(...)) 包含針對有問題的引數的特定化,則需要檢查方法內部

將函式分解成多個定義

將函式寫成許多小定義,可讓編譯器直接呼叫最適用的程式碼,甚至內嵌程式碼。

以下是「複合函式」的範例,應寫成多個定義

using LinearAlgebra

function mynorm(A)
    if isa(A, Vector)
        return sqrt(real(dot(A,A)))
    elseif isa(A, Matrix)
        return maximum(svdvals(A))
    else
        error("mynorm: invalid argument")
    end
end

可以更簡潔且有效率地寫成

mynorm(x::Vector) = sqrt(real(dot(x, x)))
mynorm(A::Matrix) = maximum(svdvals(A))

不過應注意的是,編譯器在最佳化 mynorm 範例中寫入的無效分支時非常有效率。

撰寫「型別穩定」函式

如果可能,請確保函式總是傳回相同型別的值。請考慮下列定義

pos(x) = x < 0 ? 0 : x

雖然這看起來很單純,但問題是 0 是整數(型別為 Int),而 x 可能為任何型別。因此,根據 x 的值,此函式可能會傳回兩個型別之一的值。此行為是允許的,在某些情況下可能是需要的。但可以輕鬆修正如下

pos(x) = x < 0 ? zero(x) : x

還有一個 oneunit 函數,以及一個更通用的 oftype(x, y) 函數,它會將 y 轉換為 x 的類型。

避免變更變數的類型

在函數中重複使用的變數也存在類似的「類型穩定性」問題

function foo()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end

區域變數 x 起初為整數,在一次迴圈反覆運算後變成浮點數(/ 運算子的結果)。這使得編譯器更難最佳化迴圈主體。有幾個可能的修正方式

  • 使用 x = 1.0 初始化 x
  • x 的類型明確宣告為 x::Float64 = 1
  • 使用 x = oneunit(Float64) 進行明確轉換
  • 使用第一次迴圈反覆運算初始化,為 x = 1 / rand(),然後迴圈 for i = 2:10

分離核心函數(又稱函數障礙)

許多函數遵循執行一些設定工作,然後執行許多反覆運算來進行核心運算的模式。如果可能,最好將這些核心運算放在不同的函數中。例如,以下虛構函數會傳回一個陣列,其中包含隨機選取的類型

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           for i = 1:n
               a[i] = 2
           end
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

這應該寫成

julia> function fill_twos!(a)
           for i = eachindex(a)
               a[i] = 2
           end
       end;

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           fill_twos!(a)
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

Julia 的編譯器會針對函數邊界的引數類型專門化程式碼,因此在原始實作中,它不知道迴圈中的 a 類型(因為它是隨機選取的)。因此,第二個版本通常比較快,因為內部迴圈可以重新編譯為 fill_twos! 的一部分,以適用於不同類型的 a

第二個形式通常也比較有型,而且可以重複使用更多程式碼。

此模式在 Julia Base 的多個地方使用。例如,請參閱 abstractarray.jl 中的 vcathcat,或 fill! 函數,我們可以使用它來取代撰寫自己的 fill_twos!

在處理類型不確定的資料時,會出現類似 strange_twos 的函數,例如從可能包含整數、浮點數、字串或其他內容的輸入檔案載入的資料。

具有值作為參數的類型

假設您想要建立一個 N 維陣列,其每個軸的長度為 3。此類陣列可以這樣建立

julia> A = fill(5.0, (3, 3))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

此方法非常有效:編譯器可以找出 AArray{Float64,2},因為它知道填入值的類型 (5.0::Float64) 和維度 ((3, 3)::NTuple{2,Int})。這表示編譯器可以為在同一個函數中未來使用 A 產生非常有效率的程式碼。

但現在假設您想要撰寫一個函數,在任意維度中建立一個 3×3×... 陣列;您可能會想撰寫一個函數

julia> function array3(fillval, N)
           fill(fillval, ntuple(d->3, N))
       end
array3 (generic function with 1 method)

julia> array3(5.0, 2)
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

這會運作,但 (您可以使用 @code_warntype array3(5.0, 2) 自行驗證) 問題在於無法推斷輸出類型:參數 NInt 類型的,而類型推斷無法 (也不可能) 預先預測其值。這表示使用此函數輸出的程式碼必須保守,在每次存取 A 時檢查類型;此類程式碼會非常慢。

現在,解決此類問題的一個非常好的方法是使用函數障礙技術。然而,在某些情況下,您可能想要完全消除類型不穩定性。在這種情況下,一種方法是將維度作為參數傳遞,例如通過 Val{T}()(請參閱"值類型"

julia> function array3(fillval, ::Val{N}) where N
           fill(fillval, ntuple(d->3, Val(N)))
       end
array3 (generic function with 1 method)

julia> array3(5.0, Val(2))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

Julia 有 ntuple 的特殊版本,它接受 Val{::Int} 執行個體作為第二個參數;通過將 N 作為類型參數傳遞,您可以讓編譯器知道它的「值」。因此,此版本的 array3 允許編譯器預測回傳類型。

但是,使用此類技術可能令人驚訝地微妙。例如,如果您從類似這樣的函數呼叫 array3,這將毫無幫助

function call_array3(fillval, n)
    A = array3(fillval, Val(n))
end

在此,您再次建立了相同的問題:編譯器無法猜測 n 是什麼,因此它不知道 Val(n)類型。嘗試使用 Val 但執行錯誤,在許多情況下很容易使效能變差。(只有在您有效地將 Val 與函數障礙技巧結合使用以使核心函數更有效率的情況下,才應使用上述程式碼。)

正確使用 Val 的範例如下

function filter3(A::AbstractArray{T,N}) where {T,N}
    kernel = array3(1, Val(N))
    filter(A, kernel)
end

在此範例中,N 作為參數傳遞,因此編譯器知道它的「值」。基本上,Val(T) 僅在 T 是硬編碼/文字(Val(3))或已在類型網域中指定時才有效。

濫用多重調度的危險(又名,更多關於具有值作為參數的類型)

一旦學會欣賞多重調度,就會有可以理解的傾向,會過度使用它並嘗試將它用於所有事情。例如,您可能會想像使用它來儲存資訊,例如

struct Car{Make, Model}
    year::Int
    ...more fields...
end

然後在物件上進行派送,例如 Car{:Honda,:Accord}(year, args...)

在下列任一情況為真時,這可能是值得的

  • 您需要在每個 Car 上執行 CPU 密集型處理,如果您在編譯時知道 MakeModel,並且將使用的不同 MakeModel 的總數不會太大,則效率會大幅提升。
  • 您有相同類型 Car 的同質清單要處理,以便您可以將它們全部儲存在 Array{Car{:Honda,:Accord},N} 中。

當後者成立時,處理此類同質陣列的函數可以產生良好的專業化:Julia 預先知道每個元素的類型(容器中的所有物件都有相同的具體類型),因此 Julia 可以「查詢」函數編譯時正確的方法呼叫(避免在執行時檢查的需要),從而為處理整個清單發出有效的程式碼。

當這些不成立時,您很可能不會受益;更糟的是,產生的「類型組合爆炸」將適得其反。如果 items[i+1] 的類型與 item[i] 不同,Julia 必須在執行時查詢類型,在方法表中搜尋適當的方法,決定(透過類型交集)哪一個匹配,確定它是否已經 JIT 編譯(如果不是,則執行),然後進行呼叫。實質上,您要求完整的類型系統和 JIT 編譯機制基本上執行等同於您自己的程式碼中的 switch 陳述式或字典查詢。

可以在 郵件清單 上找到一些執行時基準,比較(1)類型派送、(2)字典查詢和(3)「switch」陳述式。

或許比執行時間影響更糟的是編譯時間影響:Julia 會針對每個不同的 Car{Make, Model} 編譯專門函式;如果你有數百或數千種此類型別,則每個接受此類物件作為參數的函式(從你可能自己撰寫的 get_year 自訂函式,到 Julia Base 中的 push! 通用函式)都會有數百或數千種為其編譯的變體。這些變體每個都會增加已編譯程式碼快取的大小、方法內部清單的長度等。過度熱衷於值作為參數很容易浪費龐大資源。

按記憶體順序存取陣列,沿著欄位

Julia 中的多維陣列以欄位優先順序儲存。這表示陣列一次堆疊一欄。這可以使用 vec 函式或 [:] 語法驗證,如下所示(請注意陣列順序為 [1 3 2 4],而非 [1 2 3 4]

julia> x = [1 2; 3 4]
2×2 Matrix{Int64}:
 1  2
 3  4

julia> x[:]
4-element Vector{Int64}:
 1
 3
 2
 4

這種陣列排序慣例在許多語言中很常見,例如 Fortran、Matlab 和 R(僅舉幾例)。欄位優先排序的替代方案是列優先排序,這是 C 和 Python(numpy)等語言採用的慣例。在迴圈處理陣列時,記住陣列的排序可能會對效能產生重大影響。要記住的經驗法則是用於欄位優先陣列時,第一個索引變動最快速。這基本上表示如果切片運算式中第一個出現的是最內層迴圈索引,則迴圈執行速度會較快。請記住,使用 : 為陣列編制索引是一個隱含迴圈,會反覆存取特定維度中的所有元素;例如,萃取欄位會比萃取列更快。

考慮以下虛構範例。想像我們想要撰寫一個函式,它接受 Vector 並傳回一個正方形 Matrix,其列或欄位填滿輸入向量的副本。假設列或欄位填滿這些副本並不重要(或許其他程式碼可以輕鬆地進行適當調整)。除了建議呼叫內建 repeat 之外,我們可以想出至少四種方法來執行此操作

function copy_cols(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[:, i] = x
    end
    return out
end

function copy_rows(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[i, :] = x
    end
    return out
end

function copy_col_row(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for col = inds, row = inds
        out[row, col] = x[row]
    end
    return out
end

function copy_row_col(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for row = inds, col = inds
        out[row, col] = x[col]
    end
    return out
end

現在,我們將使用相同的隨機 10000 乘以 1 輸入向量,對這些函式進行計時

julia> x = randn(10000);

julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x))

julia> map(fmt, [copy_cols, copy_rows, copy_col_row, copy_row_col]);
copy_cols:    0.331706323
copy_rows:    1.799009911
copy_col_row: 0.415630047
copy_row_col: 1.721531501

請注意,copy_colscopy_rows 快得多。這是預期的,因為 copy_cols 遵循 Matrix 的基於欄位的記憶體配置,並一次填滿一欄。此外,copy_col_rowcopy_row_col 快得多,因為它遵循我們的經驗法則,即切片運算式中出現的第一個元素應與最內層迴圈結合。

預先配置輸出

如果函式傳回 Array 或其他複雜類型,它可能必須配置記憶體。不幸的是,配置和其相反的垃圾回收通常是重大的瓶頸。

有時,你可以透過預先配置輸出,避免在每次函式呼叫時配置記憶體。作為一個簡單的範例,比較

julia> function xinc(x)
           return [x, x+1, x+2]
       end;

julia> function loopinc()
           y = 0
           for i = 1:10^7
               ret = xinc(i)
               y += ret[2]
           end
           return y
       end;

julia> function xinc!(ret::AbstractVector{T}, x::T) where T
           ret[1] = x
           ret[2] = x+1
           ret[3] = x+2
           nothing
       end;

julia> function loopinc_prealloc()
           ret = Vector{Int}(undef, 3)
           y = 0
           for i = 1:10^7
               xinc!(ret, i)
               y += ret[2]
           end
           return y
       end;

計時結果

julia> @time loopinc()
  0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time)
50000015000000

julia> @time loopinc_prealloc()
  0.030850 seconds (6 allocations: 288 bytes)
50000015000000

預先配置還有其他優點,例如允許呼叫者控制演算法的「輸出」類型。在上述範例中,如果我們這麼希望,我們可以傳遞一個 SubArray 而不是一個 Array

極端來說,預先配置會讓你的程式碼變醜,因此可能需要效能測量和一些判斷。然而,對於「向量化」(逐元素)函式,方便的語法 x .= f.(y) 可用於原地的運算,並具有融合迴圈且沒有暫存陣列(請參閱 函式向量化的點語法)。

更多點:融合向量化運算

Julia 有特殊的 點語法,它會將任何純量函式轉換成「向量化」函式呼叫,並將任何運算子轉換成「向量化」運算子,具有嵌套「點呼叫」會融合的特殊屬性:它們在語法層級中結合為單一迴圈,而不會配置暫存陣列。如果你使用 .= 和類似的賦值運算子,結果也可以原地的儲存在預先配置的陣列中(請參閱上方)。

在線性代數的背景下,這表示即使定義了像 vector + vectorvector * scalar 這樣的運算,改用 vector .+ vectorvector .* scalar 仍然是有利的,因為產生的迴圈可以與周圍的運算融合。例如,考慮以下兩個函式

julia> f(x) = 3x.^2 + 4x + 7x.^3;

julia> fdot(x) = @. 3x^2 + 4x + 7x^3; # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3

ffdot 都計算相同的事情。然而,當應用於陣列時,fdot(在 @. 巨集的幫助下定義)顯著快得多

julia> x = rand(10^6);

julia> @time f(x);
  0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time)

julia> @time fdot(x);
  0.002790 seconds (6 allocations: 7.630 MiB)

julia> @time f.(x);
  0.002626 seconds (8 allocations: 7.630 MiB)

也就是說,fdot(x) 快十倍,並且分配 f(x) 的 1/6 記憶體,因為 f(x) 中的每個 *+ 運算都會分配一個新的暫時陣列,並在一個單獨的迴圈中執行。在此範例中,f.(x)fdot(x) 一樣快,但在許多情況下,在表達式中灑一些點比為每個向量化運算定義一個單獨的函式更方便。

考慮使用檢視來切片

在 Julia 中,陣列「切片」表達式(例如 array[1:5, :])會建立該資料的副本(在指定符號的左側除外,其中 array[1:5, :] = ... 會就地指定到 array 的那部分)。如果您對切片執行許多運算,這對於效能而言可能很好,因為使用較小的連續副本比索引原始陣列更有效率。另一方面,如果您只對切片執行一些簡單的運算,則分配和複製運算的成本可能會很大。

另一種方法是建立陣列的「檢視」,這是一個陣列物件 (SubArray),實際上會參考原始陣列的資料,而不會建立一份拷貝。(如果你寫入檢視,它也會修改原始陣列的資料。)這可以使用 view 對個別切片進行,或使用 @views 放置在表達式或程式區塊前面,對整個表達式或程式區塊進行更簡單的處理。例如

julia> fcopy(x) = sum(x[2:end-1]);

julia> @views fview(x) = sum(x[2:end-1]);

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (3 allocations: 7.629 MB)

julia> @time fview(x);
  0.001020 seconds (1 allocation: 16 bytes)

請注意 fview 版本的函式速度提升了 3 倍,而且記憶體配置減少了。

拷貝資料並不總是壞事

陣列會連續儲存在記憶體中,這有助於 CPU 向量化,而且由於快取,可以減少記憶體存取次數。這些原因與建議以欄優先順序存取陣列的原因相同 (請參閱上文)。不規則的存取模式和非連續檢視會大幅降低陣列的運算速度,因為記憶體存取是非順序的。

在重複存取之前,將不規則存取的資料拷貝到連續陣列中,可以大幅提升速度,例如以下範例。在此,在進行乘法運算之前,會以隨機洗牌的索引存取矩陣。拷貝到一般陣列會提升乘法運算的速度,即使加上拷貝和配置的成本。

julia> using Random

julia> A = randn(3000, 3000);

julia> x = randn(2000);

julia> inds = shuffle(1:3000)[1:2000];

julia> function iterated_neural_network(A, x, depth)
           for _ in 1:depth
               x .= max.(0, A * x)
           end
           argmax(x)
       end

julia> @time iterated_neural_network(view(A, inds, inds), x, 10)
  0.324903 seconds (12 allocations: 157.562 KiB)
1569

julia> @time iterated_neural_network(A[inds, inds], x, 10)
  0.054576 seconds (13 allocations: 30.671 MiB, 13.33% gc time)
1569

只要有足夠的記憶體,將檢視拷貝到陣列的成本會被在連續陣列上進行重複矩陣乘法的速度提升所抵銷。

考慮使用 StaticArrays.jl 進行小型固定大小的向量/矩陣運算

如果您的應用程式涉及許多固定大小的小型 (< 100 元素) 陣列 (亦即大小在執行前已知),那麼您可能想要考慮使用 StaticArrays.jl 套件。這個套件允許您以避免不必要的堆疊配置的方式來表示此類陣列,並允許編譯器針對陣列的大小進行專門化程式碼,例如,透過完全展開向量運算 (消除迴圈) 和將元素儲存在 CPU 暫存器中。

例如,如果您正在使用 2d 幾何進行運算,您可能會有許多使用 2 個組成向量的運算。透過使用 StaticArrays.jl 中的 SVector 類型,您可以在向量 vw 上使用方便的向量表示法和運算,例如 norm(3v - w),同時允許編譯器將程式碼展開為等同於 @inbounds hypot(3v[1]-w[1], 3v[2]-w[2]) 的最小運算。

避免使用字串內插進行 I/O

在將資料寫入檔案 (或其他 I/O 裝置) 時,形成額外的中間字串會造成額外的負擔。請使用

println(file, "$a $b")

而不是

println(file, a, " ", b)

第一個版本的程式碼會形成一個字串,然後將其寫入檔案,而第二個版本會直接將值寫入檔案。另外請注意,在某些情況下,字串內插可能較難閱讀。請考慮

println(file, "$(f(a))$(f(b))")

println(file, f(a), f(b))

在平行執行期間最佳化網路 I/O

在平行執行遠端函式時

using Distributed

responses = Vector{Any}(undef, nworkers())
@sync begin
    for (idx, pid) in enumerate(workers())
        @async responses[idx] = remotecall_fetch(foo, pid, args...)
    end
end

using Distributed

refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
    refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]

前者會對每個工作者執行單一網路往返,而後者會執行兩次網路呼叫 - 第一次由 @spawnat 執行,第二次則是由於 fetch(甚至 wait)執行。fetch/wait 也會串行執行,導致整體效能較差。

修正不建議使用的警告

已不建議使用的函式會在內部執行查詢,以便僅列印一次相關警告。此額外的查詢可能會造成顯著的效能下降,因此應根據警告建議修改所有已不建議使用的函式。

調整

以下是一些可能有助於緊密內部迴圈的小要點。

效能註解

有時,您可以透過承諾某些程式屬性來啟用更好的最佳化。

  • 使用 @inbounds 來消除表達式中的陣列邊界檢查。在執行此操作之前,請務必確定。如果下標超出邊界,您可能會遭遇當機或靜默損毀。
  • 使用 @fastmath 允許浮點數最佳化,這對於實數來說是正確的,但會導致 IEEE 數字產生差異。執行此操作時請小心,因為這可能會改變數值結果。這對應於 clang 的 -ffast-math 選項。
  • for 迴圈前面寫入 @simd 以保證迭代是獨立的,並且可以重新排序。請注意,在許多情況下,Julia 可以自動向量化程式碼,而不需要 @simd 巨集;它僅在以下情況中有益:否則這種轉換是非法的,包括允許浮點數重新關聯性和忽略依賴記憶體存取(@simd ivdep)等情況。同樣地,在聲明 @simd 時要非常小心,因為錯誤地註解具有依賴迭代的迴圈可能會導致意外的結果。特別要注意的是,某些 AbstractArray 子類型的 setindex! 本質上依賴於迭代順序。此功能為實驗性質,可能會在 Julia 的未來版本中變更或消失。

如果陣列使用非傳統索引,則使用 1:n 來索引到 AbstractArray 的常見慣用語是不安全的,並且如果關閉邊界檢查,可能會導致分段錯誤。請改用 LinearIndices(x)eachindex(x)(另請參閱 具有自訂索引的陣列)。

注意

雖然 @simd 需要直接放在最內層的 for 迴圈前面,但 @inbounds@fastmath 都可以應用於單一表達式或出現在巢狀程式碼區塊中的所有表達式,例如使用 @inbounds begin@inbounds for ...

以下是同時具有 @inbounds@simd 標記的範例(我們在此使用 @noinline 來防止最佳化器嘗試過於聰明並擊敗我們的基準測試)

@noinline function inner(x, y)
    s = zero(eltype(x))
    for i=eachindex(x)
        @inbounds s += x[i]*y[i]
    end
    return s
end

@noinline function innersimd(x, y)
    s = zero(eltype(x))
    @simd for i = eachindex(x)
        @inbounds s += x[i] * y[i]
    end
    return s
end

function timeit(n, reps)
    x = rand(Float32, n)
    y = rand(Float32, n)
    s = zero(Float64)
    time = @elapsed for j in 1:reps
        s += inner(x, y)
    end
    println("GFlop/sec        = ", 2n*reps / time*1E-9)
    time = @elapsed for j in 1:reps
        s += innersimd(x, y)
    end
    println("GFlop/sec (SIMD) = ", 2n*reps / time*1E-9)
end

timeit(1000, 1000)

在配備 2.4GHz Intel Core i5 處理器的電腦上,這會產生

GFlop/sec        = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018

GFlop/sec 衡量效能,數字越大越好。)

以下是包含三種標記的範例。此程式會先計算一維陣列的有限差分,然後評估結果的 L2 規範

function init!(u::Vector)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds @simd for i in 1:n #by asserting that `u` is a `Vector` we can assume it has 1-based indexing
        u[i] = sin(2pi*dx*i)
    end
end

function deriv!(u::Vector, du)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds du[1] = (u[2] - u[1]) / dx
    @fastmath @inbounds @simd for i in 2:n-1
        du[i] = (u[i+1] - u[i-1]) / (2*dx)
    end
    @fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end

function mynorm(u::Vector)
    n = length(u)
    T = eltype(u)
    s = zero(T)
    @fastmath @inbounds @simd for i in 1:n
        s += u[i]^2
    end
    @fastmath @inbounds return sqrt(s)
end

function main()
    n = 2000
    u = Vector{Float64}(undef, n)
    init!(u)
    du = similar(u)

    deriv!(u, du)
    nu = mynorm(du)

    @time for i in 1:10^6
        deriv!(u, du)
        nu = mynorm(du)
    end

    println(nu)
end

main()

在配備 2.7 GHz Intel Core i7 處理器的電腦上,會產生

$ julia wave.jl;
  1.207814709 seconds
4.443986180758249

$ julia --math-mode=ieee wave.jl;
  4.487083643 seconds
4.443986180758249

在此,選項 --math-mode=ieee 會停用 @fastmath 巨集,以便我們能比較結果。

在此情況下,@fastmath 的加速因子約為 3.7。這異常的大 - 一般而言,加速會較小。(在這個特定的範例中,基準的作業集夠小,可以放入處理器的 L1 快取中,因此記憶體存取延遲並未發揮作用,而運算時間則由 CPU 使用量主導。在許多實際世界的程式中並非如此。)此外,在此情況下,此最佳化並未變更結果 - 一般而言,結果會略有不同。在某些情況下,特別是對於數值不穩定的演算法,結果可能非常不同。

註解 @fastmath 會重新排列浮點運算式,例如變更評估順序,或假設某些特殊情況(inf、nan)不會發生。在此情況下(以及在此特定電腦上),主要差異在於函式 deriv 中的運算式 1 / (2*dx) 已從迴圈中提升(亦即在迴圈外計算),就像寫了 idx = 1 / (2*dx) 一樣。在迴圈中,運算式 ... / (2*dx) 隨後會變成 ... * idx,評估速度快很多。當然,編譯器套用的實際最佳化以及產生的加速在很大程度上取決於硬體。您可以使用 Julia 的 code_native 函式檢查已產生程式碼的變更。

請注意,@fastmath 也假設計算期間不會發生 NaN,這可能會導致令人驚訝的行為

julia> f(x) = isnan(x);

julia> f(NaN)
true

julia> f_fast(x) = @fastmath isnan(x);

julia> f_fast(NaN)
false

將次常數視為零

次常數,以前稱為 非正規數,在許多情況下很有用,但在某些硬體上會造成效能損失。呼叫 set_zero_subnormals(true) 允許浮點運算將次常數輸入或輸出視為零,這可能會改善某些硬體的效能。呼叫 set_zero_subnormals(false) 對次常數強制執行嚴格的 IEEE 行為。

以下是一個範例,說明次常數如何明顯影響某些硬體的效能

function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
    @assert length(a)==length(b)
    n = length(b)
    b[1] = 1                            # Boundary condition
    for i=2:n-1
        b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
    end
    b[n] = 0                            # Boundary condition
end

function heatflow(a::Vector{T}, nstep::Integer) where T
    b = similar(a)
    for t=1:div(nstep,2)                # Assume nstep is even
        timestep(b,a,T(0.1))
        timestep(a,b,T(0.1))
    end
end

heatflow(zeros(Float32,10),2)           # Force compilation
for trial=1:6
    a = zeros(Float32,1000)
    set_zero_subnormals(iseven(trial))  # Odd trials use strict IEEE arithmetic
    @time heatflow(a,1000)
end

這會產生類似以下的輸出

  0.002202 seconds (1 allocation: 4.063 KiB)
  0.001502 seconds (1 allocation: 4.063 KiB)
  0.002139 seconds (1 allocation: 4.063 KiB)
  0.001454 seconds (1 allocation: 4.063 KiB)
  0.002115 seconds (1 allocation: 4.063 KiB)
  0.001455 seconds (1 allocation: 4.063 KiB)

請注意,每個偶數迭代都顯著地更快。

這個範例會產生許多次常數,因為 a 中的值會變成一個指數遞減的曲線,隨著時間推移而逐漸平坦。

將次常數視為零應謹慎使用,因為這樣做會破壞一些恆等式,例如 x-y == 0 表示 x == y

julia> x = 3f-38; y = 2f-38;

julia> set_zero_subnormals(true); (x - y, x == y)
(0.0f0, false)

julia> set_zero_subnormals(false); (x - y, x == y)
(1.0000001f-38, false)

在某些應用程式中,將次常數歸零的替代方法是注入一點點雜訊。例如,不要使用零來初始化 a,請使用以下方式初始化

a = rand(Float32,1000) * 1.f-9

@code_warntype

巨集 @code_warntype(或其函式變體 code_warntype)有時有助於診斷與類型相關的問題。以下是一個範例

julia> @noinline pos(x) = x < 0 ? 0 : x;

julia> function f(x)
           y = pos(x)
           return sin(y*x + 1)
       end;

julia> @code_warntype f(3.2)
MethodInstance for f(::Float64)
  from f(x) @ Main REPL[9]:1
Arguments
  #self#::Core.Const(f)
  x::Float64
Locals
  y::Union{Float64, Int64}
Body::Float64
1 ─      (y = Main.pos(x))
│   %2 = (y * x)::Float64
│   %3 = (%2 + 1)::Float64
│   %4 = Main.sin(%3)::Float64
└──      return %4

解讀 @code_warntype 的輸出,就像它的表兄弟 @code_lowered@code_typed@code_llvm@code_native 一樣,需要一點練習。你的程式碼會以一種經過大量消化以產生編譯機器碼的形式呈現。大多數的表達式都註解了類型,由 ::T 表示(其中 T 可能為 Float64,例如)。@code_warntype 最重要的特徵是非具體類型會以紅色顯示;由於本文檔是用 Markdown 編寫的,沒有顏色,因此在本文檔中,紅色文字用大寫表示。

在頂部,函式的推論回傳類型顯示為 Body::Float64。接下來的幾行代表 Julia 的 SSA IR 形式中的 f 主體。編號方框是標籤,表示程式碼中跳轉(透過 goto)的目標。查看主體,你可以看到發生第一件事是呼叫 pos,回傳值已推論為 Union 類型 Union{Float64, Int64},由於它是非具體類型,因此以大寫顯示。這表示我們無法根據輸入類型得知 pos 的確切回傳類型。然而,y*x 的結果是 Float64,無論 yFloat64 還是 Int64。最終結果是 f(x::Float64) 的輸出不會在類型上不穩定,即使某些中間運算在類型上不穩定也是如此。

如何使用這些資訊取決於您。顯然,最好遠遠地修復 pos 以使其型別穩定:如果您這樣做了,f 中的所有變數都將具體化,並且其效能將最佳化。然而,在某些情況下,這種短暫型別不穩定性可能並不太重要:例如,如果 pos 從未單獨使用,則 f 的輸出為型別穩定(對於 Float64 輸入)這項事實將保護後續程式碼免於型別不穩定性所產生的影響。這在難以或無法修復型別不穩定性的情況下特別相關。在這種情況下,上述提示(例如,新增型別註解和/或分解函式)是您遏制型別不穩定性「損害」的最佳工具。此外,請注意,即使 Julia Base 也有型別不穩定的函式。例如,函式 findfirst 傳回在陣列中找到金鑰的索引,或如果找不到,則傳回 nothing,這是一個明顯的型別不穩定性。為了更容易找到可能重要的型別不穩定性,包含 missingnothingUnion 以黃色標示,而不是紅色。

以下範例可能有助於您解釋標記為包含非葉型別的表達式

  • 函式主體以 Body::Union{T1,T2}) 開頭

    • 解釋:具有不穩定回傳型別的函式
    • 建議:使回傳值型別穩定,即使您必須註解它
  • 呼叫 Main.g(%%x::Int64)::Union{Float64, Int64}

    • 解釋:呼叫型別不穩定的函式 g
    • 建議:修復函式,或在必要時註解回傳值
  • 呼叫 Base.getindex(%%x::Array{Any,1}, 1::Int64)::Any

    • 解釋:存取類型不佳的陣列元素
    • 建議:使用定義較佳類型的陣列,或在必要時註解個別元素存取的類型
  • Base.getfield(%%x, :(:data))::Array{Float64,N} where N

    • 解釋:取得非葉狀類型的欄位。在此情況下,x 的類型(例如 ArrayContainer)有一個欄位 data::Array{T}。但 Array 也需要維度 N 才能成為具體類型。
    • 建議:使用具體類型,例如 Array{T,3}Array{T,N},其中 N 現在是 ArrayContainer 的參數

擷取變數的效能

考慮定義內部函式的以下範例

function abmult(r::Int)
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

函式 abmult 回傳一個函式 f,將其引數乘以 r 的絕對值。指定給 f 的內部函式稱為「閉包」。內部函式也會用於語言的 do 區塊和產生器運算式。

這種程式碼樣式會對語言的效能造成挑戰。剖析器在將其轉換為較低層級的指令時,會大幅重組上述程式碼,將內部函式萃取到一個獨立的程式碼區塊。內部函式及其封裝範圍共用的「擷取」變數(例如 r)也會萃取到堆積分配的「方塊」,供內部和外部函式存取,因為語言規定內部範圍中的 r 必須與外部範圍(或另一個內部函式)修改 r 之後外部範圍中的 r 相同。

前一段的討論提到「剖析器」,也就是在包含 abmult 的模組第一次載入時發生的編譯階段,與其第一次被呼叫時發生的後續階段相反。剖析器並「不知道」Int 是固定型別,或陳述式 r = -r 會將 Int 轉換成另一個 Int。型別推論的神奇之處發生在編譯的後續階段。

因此,剖析器不知道 r 有固定型別 (Int),也不知道 r 在內部函式建立後不會變更值 (因此不需要方塊)。因此,剖析器會發出方塊的程式碼,其中包含具有抽象型別 (例如 Any) 的物件,這需要在 r 的每個執行個體中執行執行時期型別傳送。這可以透過對上述函式套用 @code_warntype 來驗證。封裝和執行時期型別傳送都可能導致效能損失。

如果在效能關鍵區段中使用擷取變數,則下列提示有助於確保其使用具有效能。首先,如果已知擷取變數不會變更其型別,則可以使用型別註解明確宣告這一點 (在變數上,而不是在右側)。

function abmult2(r0::Int)
    r::Int = r0
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

型別註解會部分還原由於擷取而損失的效能,因為剖析器可以將具體型別關聯到方塊中的物件。更進一步,如果擷取變數根本不需要封裝 (因為在建立封閉後不會重新指派),則可以使用 let 區塊來表示,如下所示。

function abmult3(r::Int)
    if r < 0
        r = -r
    end
    f = let r = r
            x -> x * r
    end
    return f
end

let 區塊會建立一個新的變數 r,其作用範圍僅限於內部函數。第二種技術在存在擷取變數的情況下,可恢復完整的語言效能。請注意,這是編譯器快速變動的一面,未來版本很可能不需要這種程度的程式設計師註解就能達到效能。在此同時,一些使用者貢獻的套件,例如 FastClosures 會自動插入 let 陳述式,就像在 abmult3 中一樣。

多執行緒與線性代數

本節適用於多執行緒 Julia 程式碼,在每個執行緒中執行線性代數運算。的確,這些線性代數運算涉及 BLAS / LAPACK 呼叫,它們本身就是多執行緒的。在這種情況下,必須確保核心不會因為兩種不同類型的多執行緒而超額訂閱。

Julia 編譯並使用其自己的 OpenBLAS 副本進行線性代數,其執行緒數量由環境變數 OPENBLAS_NUM_THREADS 控制。它可以在啟動 Julia 時設定為命令列選項,或在 Julia 會話期間使用 BLAS.set_num_threads(N) 修改(子模組 BLASusing LinearAlgebra 匯出)。其目前值可以使用 BLAS.get_num_threads() 存取。

當使用者未指定任何內容時,Julia 會嘗試為 OpenBLAS 執行緒數量選擇一個合理的數值(例如根據平台、Julia 版本等)。不過,通常建議手動檢查並設定數值。OpenBLAS 行為如下

  • 如果 OPENBLAS_NUM_THREADS=1,OpenBLAS 會使用呼叫的 Julia 執行緒,也就是說它「存在於」執行運算的 Julia 執行緒中。
  • 如果 OPENBLAS_NUM_THREADS=N>1,OpenBLAS 會建立並管理自己的執行緒池(總共 N 個)。所有 Julia 執行緒之間只會共用一個 OpenBLAS 執行緒池。

當你使用 JULIA_NUM_THREADS=X 以多執行緒模式啟動 Julia 時,通常建議將 OPENBLAS_NUM_THREADS=1 設為 1。根據上述行為,將 BLAS 執行緒數量增加到 N>1 很容易導致效能變差,特別是在 N<<X 的情況下。不過這只是一個經驗法則,設定每個執行緒數量的最佳方式是針對你的特定應用程式進行實驗。

其他線性代數後端

除了 OpenBLAS 之外,還有其他幾個後端可以協助提升線性代數效能。著名的範例包括 MKL.jlAppleAccelerate.jl

這些都是外部套件,因此我們不會在此詳細討論。請參閱它們各自的文件(特別是因為它們在多執行緒方面的行為與 OpenBLAS 不同)。