Parallel Computing with Julia Part I: Optimization

Dboy Liao
9 min readDec 29, 2019

--

最近有空就在 Julia Academy 上上課,複習一下 Julia 一些東西順便也研究一下一些深入一點的題目。在上 Parallel Computing 這門課的時候,有學到一些小技巧覺得不錯,就簡單寫一篇記錄一下學到的東西。

目前還沒真的用到任何平行化的東西,如果你是想看平行化程式設計相關的話,你可以跳過這一篇。

那廢話不多說,就來看看有哪些小技巧吧!

BenchmarkTools.jl

BenchmarkTools.jl 是 Julia 常用的 benchmark 套件,讓你簡單的去看到不同實作間效能差異。其中常用的 macro 包含 @benchmark@btime

@benchmark 後面接著想要測試的 expression,可得到一個 BenchmarkTrial 物件。例如 @benchmark sum([1, 2, 3]) 。另外 @btime 的功能跟 @benchmark 類似,只是輸出內容比較陽春,但在簡單測試時也是不錯的選擇。用起來大概會是像這樣:

可以看到 @benchmark 其實幫你重複跑了非常多次,然後統計每次所需的時間,另一方面 @btime 就只是單純紀錄求值所需要的時間而已,並不會去重複試驗 (基本上跟 @time 類似) 。

在使用 BenchmarkTools 時有個小陷阱需要注意,像上面看到的 memory allocation 其實是來自於 [1, 2, 3]。也就是說,sum(...) 中的陣列的生成也被納入了時間與所需記憶體的計算之中,然而這不一定是我們想要的結果,因為通常我們就只是想知道某個函數本身所需要的時間與記憶體,並不會把參數的求值也納入考量。

為了排除這個狀況,BenchmarkTools官網有特別提到需要用 $ 去做插值,像是這樣:

從上面的範例就可以知道,使用 $ 去插值之後,不必要的 memory allocation 就被排除了。

macro

另外 Julia 玩久了,雖說 macro 是蠻強大的特性,但對初學者來說總是覺得不知道到底背後被做了什麼事。這次上這門課有學到一個蠻有用的 macro 叫 @macroexpand,可以把一個 macro 展開,讓你看到每個 macro 後面到底做了什麼事。像是 @doc 會幫你把某個物件的文件叫出來給你看,用 @macroexpand 之後你就會看到:

簡單的說,@doc 其實只是幫你在背後呼叫 Base.Docs.doc 這個 function。

試一下就知道:

成功把 sum 的文件叫出來啦!

最佳化:LLVM IR 與 SIMD

在講平行化運算之前,其實必須先熟悉怎麼優化單行程單線程的程式,對於 Julia 怎麼執行、怎麼管理記憶體要先有一些基本認知,才能幫助你寫出好的平行化程式。

或許你之前聽過一些傳言: Julia 的迴圈爆炸快。但有沒有想過說為什麼 Julia 的迴圈會爆快呢?

目前上這門課到現在我知道的,主要是因為 Julia 的 compiler 很聰明,會自動偵測你的迴圈是否可以套用 Single Instruction Multiple Data (SIMD) 又或者是自動生成包含一般迴圈與 SIMD 兩種 branch 在 LLVM IR 裡,在可以使用 SIMD 時會自動跳到 SIMD 的 loop 。

空說可能沒感覺,課堂上也有非常棒的範例,這邊就簡單介紹一下。

自動套用 SIMD

考慮以下的 simplesum 函數:

function simplesum(data)
result = zero(eltype(data[1]))
for idx in eachindex(data)
@inbounds result += data[idx]
end
result
end

其中 @inbounds 是必須的原因是,在 Julia 裡如果要套用 SIMD,必須是 single line code,白話來說就是不能有 branch (if…else 等等)。而一般的 array access (像是 data[idx]),在生成的 llvm IR code 裡會自動加上 boundary check,而這些 boundary check 會讓 Julia compiler 無法生成 SIMD loop ,所以必須手動加上。

使用 @code_llvm 可以看到當我們使用這個 simplesum 的時候,Julia 的 compiler 是怎麼生成 LLVM IR code 的:

我第一眼看到的時候心想: 媽呀我要看懂這堆鬼東西嗎?

不過如果你只是想要知道 Julia 有沒有幫你套用 SIMD,你只需要找看看有沒有 vector.body 這個 section:

其中 %vec.phivec.phi12 等等就是 Julia 把這個 for loop 打散成 4 組 data 然後分別平行化運算之後的 code。所以說雖然我們沒有叫 Julia 去使用 SIMD,但它還是很聰明的幫我們做了這層最佳化。

但這不是總是成立的,能否套用 SIMD 也跟資料型態很有關係。譬如說如果我們把 [1, 2, 3] 換成 [1.0, 2.0, 3.0],結果就不一樣了:

Array{Float64, 1} 的情況下,Julia 並沒有幫你套用 SIMD,這又是為什麼呢?主要原因在於在浮點數的情況下,因為套用 SIMD 之後他們被求和的順序不一定,導致 Trucation Error 等等數值運算上的偏差不一定,為求保持函數的穩定,Julia 會放棄使用 SIMD。

但在某些應用上,這些 error 或許是可以接受的,那有沒有辦法強制讓 Julia 產生 SIMD 的 code 呢?

答案是肯定的,只要加上 @simd 這個 macro 即可:

function simd_sum(data)
result = zero(eltype(data[1]))
@simd for idx in eachindex(data)
@inbounds result += data[idx]
end
result
end

你就會看到熟悉的 vector.body 又回到我們的 llvm IR code 中了:

這個時候我們可以比較一下有無 SIMD 帶來的影響:

不難發現雖說 simd 的版本比較快,但精度上是一般 loop 的版本比較高。

小孩子才做選擇,我兩個都要

上面有提到 Julia 的 compiler 有些時候可能還會在同一份 llvm IR 裡產生一般 loop 與 simd loop 兩個 branch ,在適當的時候使用其中一個 branch 。我自己試真的沒試出什麼好例子,只好繼續借用上課用的的例子:

function diff!(data, result=nothing)
if result === nothing
result = typeof(data)(undef, size(data))
end
result[1] = data[1]
for idx in 2:length(data)
@inbounds result[idx] = data[idx] - data[idx-1]
end
result
end

@code_llvm 看一下:

其中有趣的地方是,在 vector.body 前面多了一個 vector.memcheck :

vector.memcheck 最後一行 br i1 %memcheck.conflict, label %scalar.ph, label %vector.ph 基本上就是在說如果memcheck.conflict 為 true,則跳到 %scalar.ph 這個 label ,反之就是 %vector.ph。其中 %vector.ph 會跳到vector.body

這邊就不難想像 Julia compiler 其實幫你生成了一個 branch ,在可以使用 SIMD 的時候自動跳到 SIMD 的 branch 上。

結語

目前上課就筆記到這裡,接下來才正式進入平行化運算的實作,但不得不說對於想要理解或掌握 Julia compiler 的人來說,上到這邊的技術含金量也頗多,提供的例子也都很簡單好懂,蠻推薦有興趣的人就去 Julia Academy 上一上課吧!

下星期我應該就會繼續看後面的章節,到時候再看看有什麼筆記可以分享給大家囉。Happy Julia Programming!

References

--

--

Dboy Liao
Dboy Liao

Written by Dboy Liao

Code Writer, Math Enthusiast and Data Scientist, yet.

Responses (2)