Julia 有優異的效能,但是它的 JIT 讓套件的載入時間(using)過於冗長。

Julia JIT 會在套件載入時期以及第一次使用函式時期進行編譯,若是有編譯完成的版本,便可以直接使用,避免編譯時間。儘管 Julia 將程式碼進行編譯,得到了絕佳的執行時間,但是套件載入的第一次編譯時間成為了大家的痛點。

為了解決這樣的痛點,開發者社群做了不少努力,主要是 PackageCompiler.jl 套件,讓套件可以預先進行編譯,而後來也補充了不少 binary package 來支援套件編譯。

PackageCompiler.jl 套件可以將 Julia 程式碼編譯成可執行檔以及動態函式庫(.so),支援套件編譯成動態函式庫,以及增量編譯(incremental compilation)。

近期 PackageCompiler.jl 套件正式發佈 v1.0,正式成熟可以使用了!本文將會介紹如何將套件編譯成動態函式庫,並且在啟動時載入,來避免過長的載入及編譯時間。

動態語言編譯

在動態語言中,編譯一直是一個大問題。編譯時,需要知道更多底層的細節,像是變數的型別。例如 foo(a::Any, b::Any) 這樣的函式在動態語言中非常常見。Julia 會在呼叫 foo(3, 4) 時,經由型別推斷(type inference)知道它需要編譯 foo(a::Int64, b::Int64) 這樣的方法出來。當下次呼叫 foo(3., 4.) 時,則需要編譯 foo(a::Float64, b::Float64)。然而,要完整的編譯 foo 這個函式,需要編譯多少種方法呢?在型別的排列組合上會造成組合爆炸的問題,讓需要編譯的方法多到難以處理。這一直以來是動態語言無法完整編譯的問題點。

那麼 Julia 怎麼做?PackageCompiler.jl 套件的編譯來自於 JIT,所以它直接將 Julia session 中已經編譯的函式儲存下來,以利後續使用。編譯的函式會以動態函式庫(.so)的形式儲存,就如同其他語言一樣。然而,Julia 仍然沒有完整編譯,Julia 只有部份編譯便存入動態函式庫。若是需要使用到未編譯的函式,就會在使用時 JIT 去處理。或許可以藉由套件的測試來增加編譯的函式數目,例如提高測試的覆蓋度來儘可能觸發各式各樣的方法進行編譯,然後儲存下來。

編譯套件

這邊我以編譯 Plots.jl 套件為例,繪圖相關套件的載入時間總是異常地久,所以我選擇 Plots.jl 套件來舉例。

請確定 Julia 中已經安裝 Plots.jl 套件。

1
2
3
4
julia> using PackageCompiler

julia> create_sysimage(:Plots; sysimage_path="Plots.so")
[ Info: PackageCompiler: creating system image object file, this might take a while...

create_sysimage 會編譯並產生 sysimage,這個 image 可以在 Julia 啟動時一併載入,它會以動態函式庫的形式儲存下來。第一個參數請指定需要編譯的套件名稱,可以同時編譯多個套件,例如 [:Plots, :Gadfly]。關鍵字參數 sysimage_path="Plots.so" 需要指定 system image 的檔名。執行後需要一段時間進行編譯。

1
2
shell> ls
Plots.so

編譯完成後便可以在目前的資料夾下看到編譯完成的檔案 Plots.so

在下次使用時使用的話,請在啟動 Julia 時加入參數 -JPlots.so

1
2
3
julia> exit()

$ julia -JPlots.so

如此,我們就可以看到在啟動 Julia 之後載入的套件就有 Plots.jl 囉!Plots.jl 套件的載入時間被包含在 Julia 啟動時間中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
julia> Base.loaded_modules
Dict{Base.PkgId,Module} with 72 entries:
Future [9fa8497b-333b-5362-9e8d-4d0656e87820] => Future
Requires [ae029012-a4dd-5104-9daa-d747884805df] => Requires
RecipesBase [3cdcf5f2-1ef4-517c-9805-6587b60abb01] => RecipesBase
InteractiveUtils [b77e0a4c-d291-57a0-90e8-8db25a27a240] => InteractiveUtils
Pkg [44cfe95a-1eb2-52ea-b672-e2afdf69b78f] => Pkg
Printf [de0858da-6303-5e67-8744-51eddeeeb8d7] => Printf
Base64 [2a0f44e3-6c83-55bd-87e4-b1978d98bd5f] => Base64
Test [8dfed614-e22c-5e08-85e1-65c5234f0b40] => Test
Reexport [189a3867-3050-52da-a836-e630ba90ab69] => Reexport
Dates [ade2ca70-3891-5945-98fb-dc099432e06a] => Dates
DelimitedFiles [8bb1440f-4735-579b-a4ab-409b98df4dab] => DelimitedFiles
LAME_jll [c1c5ebd0-6772-5130-a774-d5fcae4a789d] => LAME_jll
Missings [e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28] => Missings
__PackagePrecompilationStatementModule [top-level] => __PackagePrecompilationStatementModule
Opus_jll [91d4177d-7536-5919-b921-800302f37372] => Opus_jll
StatsBase [2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91] => StatsBase
libfdk_aac_jll [f638f0a6-7fb0-5443-88ba-1cc74229b280] => libfdk_aac_jll
libvorbis_jll [f27f6e37-5d2b-51aa-960f-b287f2bc3b7a] => libvorbis_jll
REPL [3fa0cd96-eef1-5676-8a61-b3b8758bbffb] => REPL
x265_jll [dfaa095f-4041-5dcd-9319-2fabd8486b76] => x265_jll
LibGit2 [76f85450-5226-5b5a-8eaa-529ad045b433] => LibGit2
Distributed [8ba89e20-285c-5b6f-9357-94700520ee1b] => Distributed
Plots [91a5bcdd-55d7-5caf-9e0b-520d859cae80] => Plots
LinearAlgebra [37e2e46d-f89d-539d-b4ee-838fcccc9c8e] => LinearAlgebra
⋮ => ⋮

julia> Plots.plot
plot (generic function with 3 methods)

編譯到預設位置

Julia 在系統中有 sys.so,它預設會在 Julia 啟動時載入,我們可以利用預先編譯好的函式庫來取代它,就可以不用參數的情況下自動載入。

1
create_sysimage([:Debugger, :OhMyREPL]; replace_default=true)

sysimage_path 換成 replace_default=true,來將編譯的函式庫取代 sys.so,它會將原本的 sys.so 備份到 sys.so.backup,並將編譯好的 sys.so 寫入。

增量編譯(incremental compilation)

sysimage 可以進行增量編譯。增量編譯是指既有的函式並不會重新編譯,只會編譯那些新加入的函式。如果已經有 sysimage,那麼下次呼叫 create_sysimage 時,只會編譯新加入的部份,並且用新的函式庫檔案取代舊的。

如果你想要關閉這個功能,那需要加入 incremental=false 來關閉增量編譯,這樣就會編譯一個全新的函式庫。

利用套件的測試程式碼做編譯

一般而言,在動態語言中,我們無法編譯所有可能的方法,取而代之的是,我們可以儘可能編譯更多方法。編譯更多方法越可能可以避免 Julia 啟動 JIT 在執行時期去編譯新的方法。這時候我們可以利用套件中的測試程式碼,當套件中自帶的測試程式碼,如果覆蓋率夠高,那麼就可以有越完善的編譯。這時候我們可以在編譯之前執行測試來促使 JIT 編譯各種方法。

我們可以利用以下程式碼來執行測試,並促使 JIT 進行編譯。

1
2
import Plots
include(joinpath(pkgdir(Plots), "test", "runtests.jl"))

套件的測試期間常常會有其他的相依套件,缺乏相依套件都會使測試失敗。需要不斷執行測試,並且檢查及安裝缺失的相依套件。

等測試都執行完成了,就可以進行套件編譯了。

編譯被使用的方法

如果需要編譯被使用的方法,例如在特定腳本被使用到,這時候我們需要更細緻的編譯方式。我們需要知道哪些方法在腳本當中被使用到,這時候我們可以在啟動 Julia 時加入 --trace-compile=precompile.jl--trace-compile 會讓 Julia 紀錄下哪些方法有被編譯過,並且輸出在 precompile.jl 檔案中紀錄下來。

--trace-compile 會紀錄哪些方法需要預編譯(precompilation)。例如執行 julia --trace-compile=precompile.jl -e '1+1' 會得到一個 precompile.jl 檔案,其中測試了 1+1 的預編譯,並且紀錄下來。因此,會在 precompile.jl 檔案中看到以下內容:

1
2
3
4
precompile(Tuple{typeof(Base.similar), Array{Base.Grisu.Bignums.Bignum, 1}})
precompile(Tuple{typeof(Base.length), Array{Base.Grisu.Bignums.Bignum, 1}})
precompile(Tuple{typeof(Base.deepcopy_internal), Any, Base.IdDict{Any, Any}})
precompile(Tuple{typeof(Base.deepcopy_internal), Array{UInt32, 1}, Base.IdDict{Any, Any}})

這就是 --trace-compile 將需要預編譯的方法輸出的結果。

我們可以將這些結果放到 create_sysimage 中。可以在 create_sysimage 加入關鍵字參數 precompile_statements_file="precompile.jl",並且指定剛剛輸出的預編譯紀錄檔 precompile.jl。它會讓 Julia 根據紀錄檔中所需要使用到的方法進行編譯。如此就可以編譯在腳本當中被使用到的方法了!

Ref: https://julialang.github.io/PackageCompiler.jl/dev/sysimages/