最近有空就在 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.phi
、 vec.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!