目次
はじめに
以前、
MATLABのコードの高速化手法や
Pythonコードの高速化の手法を紹介しましたが、
今回は、下記の公式記事を元に、
Juliaのコードを高速化する方法をまとめたいと思います。
Juliaは普通に使用しても十分高速ですが、
下記のTipsを利用することで、
より高速化することができます。
Juliaという言語そのものに関しては、
下記の記事を参照ください。
Juliaを高速化するために注意すべきこと
下記はJuliaのコードにおいて、
処理を高速化するために注意すべきことをまとめたものです。
グローバル変数を避ける
グローバル変数は、数値を保持することが可能ですが、
そのグローバル変数の型や値は、
任意のタイミングで変更される可能性があります。
つまり、コンパイラとしては、
そのコードを最適化するのが難しくなってしまいます。
どんな時でもできるだけ、
すべての変数はローカルか、
または関数の引数として与えられるべきです。
またすべてのパフォーマンスが重要で、
ベンチマークを取っているコードは
関数の中に書かれるべきです。
一般的に、グローバル変数は定数値として利用されるため、
もしグローバル変数をJuliaで使う場合、下記のようにconst修飾子を使うと、性能が向上します。
const DEFAULT_VAL = 0
定数でないグローバル変数を使う場合、
そのグローバル変数に型情報を追加することで、
コードを最適化することができます。
global x y= f(x::Int + 1)
しかし、グローバル変数を使わずに、
関数の入力と出力のみに依存するようなコードを書くことは、
コードのパフォーマンスだけでなく、
コードの可読性や再利用性を向上させることができます。
@timeでパフォーマンスを計測し、メモリアロケーションに注意を払うこと
Juliaでは、コードのパフォーマンスを計測するのに、
便利なマクロである@timeがあります。
このマクロでは関数の前に@timeとすると、
コード実行時に、その関数の計算時間とメモリアロケーションを表示できます。
もし使用メモリが想像よりも大きい場合、
そのコードは型の選択等にミスがある可能性が多く、
コードを改良できる可能性が多いです。
使用メモリを減らすと、計算時間も短くなることが多いです。
もし、より精密なパフォーマンスを計測したい場合は、
BenchmarkTools.jl を使うことができます。
このツールは関数を複数回呼んで、
より精密な計算結果を得ることができます。
パフォーマンス関連ツールを使う
Juliaには、コードの問題を解析したり、
コードを改善してくれるツール群があります。
これらを積極的に使用しましょう。
一つ目はProfilingマクロです。
https://docs.julialang.org/en/stable/manual/profile/#Profiling-1
これを使うことでコードのパフォーマンスを簡単に計測したり、
コードのボトルネックを簡単に計測することができます。
もしより複雑なプロジェクトをプロファイリングしたい場合は、
Profile Viewパッケージを使いましょう。
コードのボトルネックを可視化することができます。
もし、意図しないような巨大なメモリアロケーションが発生していることを、
@timeや@allocatedで発見した場合、
あなたのコードには問題がある場合が多いです。
もし問題を発見できなかった場合、
—track-allocation=user オプションを使ってJuliaを実行し、
*.memファイルを確認してください。
こちらの手法に関しては、下記を参照ください。
https://docs.julialang.org/en/stable/manual/profile/#Memory-allocation-analysis-1
こちらのLintパッケージを使うことで、
いくつかの種類のプログラミングの問題を自動解析することができます。
抽象型のコンテナは避ける
配列のような型をついう時は、
できるだけ、抽象的な型は避けた方が良いです。
例えば、下記のようなJuliaのコードがあったとします。
A = Real[] If (f = rand())< .8 push!(a, f) end
しかし、上記のコードではaは必ずFloat64になるので、
a = Float64[]として初期化したほうが良いです。
なぜなら、Realの型はどんなReadなオブジェクトでも格納できるように、
配列が確保されるからです。
詳細は下記を参照ください。
型宣言
多くの言語において、
型宣言はオプションであり、
型宣言を追加することは、
コードを高速化する上で非常に重要です。
しかし、Juliaでは少し違います。
Juliaでは型情報はすべて、
コンパイラが認識するものだからです。
しかし、いくつかの特別な状況において、
型宣言は有効なものになります。
抽象型のフィードを避ける
下記のようなコードで、
特に型を指定せずにフィールドを指定できます。
struct MyAmbiguousTyoe
a
end
このaというフィールドはどのような型にもなれます。
これはしばしば有効ですが、問題もあります。
このMyAmbiguousTypeのオブジェクトに関しては、
コンパイラはパフォーマンスの良いマシンコードを生成できないのです。
なぜなら、コンパイラは変数の型を決定する際に、
この型では変数の値ではなく、
使われ方によって型を決定する必要があるからです。
つまり、型の決定はランタイムで実行する必要があるため、
コードのパフォーマンスを低下させてしまいます。
この問題を解決するには、
フィールドに型宣言する方法があります。
struct MyTyoe a::Float64 end
キーワード引数の型を宣言する
キーワード引数に対しても 下記のように型を指定できます。
function with_keyword(x; name::Int = 1) ... end
上記のように関数の引数の型を指定すると、
関数の中のパフォーマンスは変わりませんが、
関数の呼び出しのパフォーマンスは向上します。
ちなみに、動的リストを引数とすると、
コードのパフォーマンスが悪くなりがちなので、
パフォーマンスが重要なコードには注意が必要です。
関数を複数の定義に分ける
複数の小さな処理で構成される関数は、
コンパイラによるインライン化が可能であるため、
パフォーマンスが向上する可能性があります。 例えば、下記のような引数の型に応じて、
処理を変化させる関数を書いた場合、
function norm(A) if isa(A, Vector) return sqrt(real(dot(A,A))) elseif isa(A, Matrix) return maximum(svd(A)[2]) else error("norm: invalid argument") end end
上記のようなコードは、
下記のように複数の細かい定義に分けたほうが
性能が高くなります。
norm(x::Vector) = sqrt(real(dot(x,x))) norm(A::Matrix) = maximum(svd(A)[2])
型安定なコードを書く
もし可能であれば、
関数は常に同じ型の値を返すようにするべきです。
例えば、下記の関数関数を考えてみます。
pos(x) = x < 0 ? 0 : x
この関数の問題点は、
この関数は0というIntを返す可能性がある場合と、
Xという任意の型の値を返す可能性があるということです。
これは関数のパフォーマンスを低下させる可能性があります。
これは下記のようなコードにすることで、
型安定な関数にすることができます。
pos(x) = x < 0 ? zero(x) : x
上記のzeroという型は、
xの型に応じて、0を意味する値を返してくれます。
Juliaにはzero()以外にも、one()やoftype(x, y)という関数が準備されており、
これらを使うことで型安定な関数にすることができます。
変数の型が変化しないようにする
ある変数の中で、同じ変数を複数回利用すると、
型安全の問題が発生しやすくなります。
例えば、下記のような関数において、
function foo() x = 1 for i = 1:10 x = x / bar() end end
変数xは初め整数値として初期化されますが、
その後ループの中では/演算子より、floatの型になります。
このようなコードはコンパイラはループの中を
最適化するのが非常に難しくなります。
このコードを型安全にするには、下記のような方法があります。
x を x = 1.0として初期化する。
xの型を明示的にfloatnにする。: x::Float64 = 1
oneunitという明示的な型変換を使う。
コア関数を分ける
多くの関数は、変数のセットアップをしたあと、
それらの変数を使って、
複数の繰り返し計算をします。
このような場合、
もし可能であれば、コアとなる関数を分離した方が良いです。 例えば、下記のような関数があるとします。
function strange_two(n) A = Vector{rand(Bool) ? Int64:Float64}(n) for i = 1:n a[i] = 2 end return a end
このコードは引数の次元分のすべての要素が2である
配列を返す関数ですが、
もし可能であれば、
下記のように書いた方が良いです。
function fill_twos!(a) for i=1:length(a) a[i] = 2 end end function strange_two(n) A = Vector{rand(Bool) ? Int64:Float64}(n) fill_twos!(a) return a end
なぜならJuliaのコンパイラは、
変数の型を関数毎に評価しているからです。
一つ目の実装では、コンパイラはaの型がわからないため、
その下のループのコードを最適化することができません。
しかし、二つ目のバージョンはfill_twos関数は、
それぞれ別の型の入力毎に再コンパイルされるため、
より高速なコードが生成される可能性があります。
この手法はJulaの標準ライブラリでも、
しばしば利用されています。
(abstractarray.jlのhvcat_fill関数など)
配列はメモリの順番に列ベースでアクセスする
Juliaでは多次元の配列は、
列ベースで格納されています。
つまり、列ベースでの一次元配列には
[:]の演算子を使うことで、簡単に変換できます。
julia> x = [1 2; 3 4] 2×2 Array{Int64,2}: 1 2 3 4
julia> x[:] 4-element Array{Int64,1}: 1 3 2 4
この列ベースのメモリ格納は、
FortranやMatlab, Rなどで採用されていますが、
CやPythonでは行ベースが採用されています。
これは、行列のスライスなどを多様する時に、
プログラムのパフォーマンスに大きな影響を与えます。
例えば、ループ中では下記のように、
下記のように、行ベースでデータを格納するよりも、
function copy_cols(x::Vector{T}) where T n = size(x, 1) out = Array{T}(n, n) for i = 1:n out[i, :] = x end out end
列ベースでデータを追加したほうが、
計算が高速です。
function copy_cols(x::Vector{T}) where T n = size(x, 1) out = Array{T}(n, n) for i = 1:n out[:, i] = x end out end
返り値を事前にメモリ割り当てする
あなたの関数が、Arrayやその他の複雑な型の
データを返す場合、
事前にメモリを割り当てておくと良い場合があります。
例えば、下記のようなリストを返す関数がある場合、
下記のようにループで関数を呼ぶ場合は、
毎回、変数のメモリ割当てが発生してしまいます。
function xinc(x) return [x, x+1, x+2] end function loopinc() y = 0 for i = 1:10^7 ret = xinc(i) y += ret[2] end y end
そのような場合、
下記のように、事前に出力のデータを生成し、
その後毎回関数の中で、中身を変更するだけにすると、
計算パフォーマンスを向上させることができます。
function xinc!(ret::AbstractVector{T}, x::T) where T ret[1] = x ret[2] = x+1 ret[3] = x+2 nothing end function loopinc_prealloc() ret = Array{Int}(3) y = 0 for i = 1:10^7 xinc!(ret, i) y += ret[2] end y end
しかしこの方法はコードを汚くしがちなので、
パフォーマンス計測を使って、
この手法を利用すべきかを検討したほうが良いでしょう。
ドット演算子を使う
Juliaには、ドット演算子という特別な演算子があります。
これは、任意のスカラー関数を、
ベクトル関数に変更したり、
任意のスカラー演算子をベクトル演算子に変えるものです。
このベクトル演算子をうまく使うことで、
無駄な一時変数のアロケーションを減らすことができ、
結果的に計算速度を向上させることができます。
スライスのViewを使うことを検討する
Juliaでは、配列のスライスは配列のコピーを作ってしまいます。
(左辺にスライスがある場合はコピーは作られません。)
これは大量のスライスを処理する場合は、
配列のコピーを作ったほうが良いですが、
少しのスライスの処理であれば、
インデックスのみを保存した方が計算性能が高いでしょう。
そこで便利なのは、配列のViewです。
これは配列の各要素の参照をあつめた配列データです。
このViewを作る場合は、配列のコピーは生成されません。
(その代わり、元の入れるの中身を修正すると、
Viewの中身も変化します。)
viewを作る場合は、view()関数を使うか、
@viewsマクロを使うとviewを作ることができます。
I/Oに対する文字列補間を避ける
ファイルへのデータ書き込みなど、
I/Oに対する出力では、文字列補間は避けたほうが良いです。
つまり、下記のようなコードではなく、
println(file, "$a $b")
下記のように書くべきです。
println(file, a, " ", b)
Depreciation Warningを修正する
すでにDeprecateされた関数や機能を使うと、
JuliaではDeprecation Warningが発報されますが、
このWarningは、大量に発生しないように制御されています。
しかし、この処理は計算リソースを消費する処理なので、
できるだけDeprecation Warningは修正するようにしましょう。
細かいTips
いくつか細かい計算パフォーマンス向上のための
tipsがあります。
不必要な配列を使わない (sum([x,y,z] よりx+y+zを使う)
abs(z)2よりabs2(z)を使う
trunc(x/y)より、div(x,y)を使う
floor(x/y)より、fld(x,y)を使う
ceil(x/y)よりcld(x,y)を使う
パフォーマンス向上のためのマクロ
いくつかのマクロを使うことで、
コードを高速化することができます。
@inbounds 配列の境界チェックをしなくします。コードは高速になりますが、配列がオーバフローすると急にコードがクラッシュしたりします。
@fastmath 浮動小数点の計算をさいt経過しますが、IEEEの数値計算とは若干計算結果が異なります。clangの-ffast-mathオプションと同じ機能です。
@simd forループの前に置くと、ベクター処理されます。しかし、この機能はexperimentalのようです。
参考資料
MyEnigma Supporters
もしこの記事が参考になり、
ブログをサポートしたいと思われた方は、
こちらからよろしくお願いします。