Elixir から Rust の関数を呼ぶことで、円周率 $\pi$ の近似値を求める計算を高速化する。Erlang VM の NIF(Native Implemented Functions)という仕組みで実現するので、同じ方法で Erlang から Rust の関数を呼ぶこともできる。
Qiita では同じようなタイトルの記事が 各言語でシリーズ化(?) されているので、それに便乗させてもらった。ただそれらでは、重い処理として再帰型のフィボナッチ数列関数が使われているのだが、それだとマルチコアプロセッサで並行計算(parallel 計算)させるのが難しいので、今回は簡単に parallel 化できる、円周率の近似計算を行うことにした。
進めかた
計算にかかった時間を計りながら、以下の段取りで進めていく。
- Elixir:シングルプロセスで計算
- Elixir:マルチプロセスで計算
- Elixir:HiPE でネイティブコード化
- Elixir から Rust のマルチスレッドプログラムを呼び出す
Elixir とは?
- Elixir はスケールしやすくメンテナンスしやすいアプリケーションを作るための動的な関数型言語
- Erlang VM 上で実行される
- Erlang VM は低レイテンシで、分散型かつ耐障害性のあるシステムとして知られており、Web 開発や組み込みソフトウェアの領域で使われて成功している
Elixir の最初の安定版 1.0 は、2014年9月にリリースされた。2016年1月現在の最新安定版は 1.2.0。「動的」型付け、強い型付けを行い、動的型付けを活かして、プログラムの実行中に任意のモジュールのコードをアップグレードすることもできる。Erlang VM と Erlang/OTP フレームワークから、堅牢性とスケーラビリティーを受け継いでおり、高負荷な状況でも安定したシステムを構築できる。
さらにメタプログラミングが可能な言語にしたことで、ウェブサービスを中心とするアプリケーションの開発生産性が極めて高いのが特徴だ。ウェブアプリケーション・フレームワークの Phoenix と、データベースクエリ用 DSL 言語の Ecto が、キラーアプリになっている。
Rust とは?
Rust は安全性と速度にフォーカスしたシステムプログラミング向けの言語で、以下の特徴を持つ。
- ゼロコスト抽象化による高速性。モダンな言語に見られる高度な抽象化をサポートするが、解析をコンパイル時に行なうため、実行時のオーバーヘッドが極めて小さい。C++ で書かれたプログラムに近い実行効率にすることを目標に掲げている
- メモリーへの不正なアクセス(segfaults)を防止するためのチェックをコンパイル時に行う
- マルチスレッドプログラミングの安全性を保証するためのチェックをコンパイル時に行う
Rust の最初の安定版 1.0 は、2015年5月にリリースされたばかり。2016年1月現在の最新安定版は 1.5。GC(ガベージコレクタ)を持たないが、所有権システムという、コンパイル時にスタックやヒープ領域の所有権とその借用、そして寿命を解析するしくみにより、segfaults を未然に防ぐことができるのが最大の特徴になっている。
また、SML、OCaml、Haskell などの関数型言語から強い影響を受けており、「静的」型付け、強い型付けはもちろん、高度な型推論を行う。Elixir のプロトコルや Haskell の型クラスに似た「トレイト」により、厳格な型システムの下で、多相な関数が書けるようになっている。さらに、Erlang のアクターモデルや、軽量プロセスの異常終了検知モデルも参考にしている。これらの特徴から、データの型の安全性はもちろん、マルチスレッドプログラムの競合にまつわる安全性までもコンパイル時に保証できるしくみになっている。その一方で、システムプログラミングを行う開発者から敬遠されそうな、関数型言語っぽいプログラミングスタイルは極力排除されている。
Mozilla がスポンサーとなって開発が進められており、Mozilla Research の研究開発プロジェクト「Servo, Parallel Browser Engine」の開発言語に採用されている1。
使用した環境
今回使用した環境は以下の通り。
- Elixir 1.2.0 + Erlang/OTP 18.2.1
- Rust 1.5
- OS
- FreeBSD 10.2-RELEASE
- Arch Linux
- 2つの OS で同様の傾向が見られた。本記事には FreeBSD の結果を掲載
- マシン
- Mac mini(Mid 2012)
- 2.60GHz動作のクアッドコア Core i7 3720QM
- Hyper Threading により8つの論理コアプロセッサとして動作する
- 16GB RAM
- 上記の OS は直にインストールされている。(Mac OS X の VMware や xhyve といった仮想マシンハイパーバイザーは使っていない)
Erlang/OTP は kerl でビルドし、その際、以下の configure オプションを指定した。
--enable-dirty-schedulers
--enable-hipe --enable-native-libs --enable-fp-exceptions
--enable-smp-support --enable-threads --enable-kernel-poll
Elixir と Rust 共に、FreeBSD と Arch Linux で用意されているバイナリーパッケージを利用した。
なお、Rust は x86_68 系プロセッサの SIMD 命令を用いた最適化ができるようだが、今回生成された機械語命令には SIMD 系の命令は含まれていなかった。SIMD に未対応のプロセッサーもあるので、FreeBSD や Arch Linux で用意されているバイナリーパッケージでは、SIMD のサポートが無効になっているようだ2。
円周率の求めかた
では、円周率 $\pi$ の近似値をプログラムで求めてみよう。$\pi$ の小数点以下最初の15桁は、以下のようになる。
\pi = 3.14159\ 26535\ 89793\ \dotsb
最初に断っておくと、今回は簡単のために64ビット浮動小数点型の数値を用いるので、いくら計算を続けても大した精度は得られない。試した範囲では、小数点以下8桁くらいまでが限界のようだ。
計算方法は、数値積分法の長方形近似(左点則)を採用する。こう書くと難しそうに思えるが、意外に簡単だ。
まず半径 $r = 1$ の円を考える。円の面積 $S$ を求める公式に代入すると、この条件では $S = \pi$ になるとわかる。
\begin{eqnarray}
S &=& \pi \cdot r^2 \\
&=& \pi \cdot 1 \\
&=& \pi
\end{eqnarray}
この円を中心から4つに分割し、その1片(四分円、しぶんえん)の面積を数値積分法で近似する。
数値積分法の長方形近似は、面積を求めたい範囲(図の四分円)を $X$ 軸方向に $N$ 等分し、長方形を敷き詰めて、それらの面積の和で近似する方法。左点則では、長方形の左上の点が、境界に接するようにする。図でわかるように、はみ出した部分は誤差になる。分割数を増やすほど誤差が小さくなるが、その分、計算に時間がかかるようになる。
具体的な式に落としていこう。円の中心(図の左下)が $ XY$ 座標の原点とする。$N$ 個に分割した四分円の面積を $S_0$、個々の長方形の幅を $w$、左端の長方形の左上の頂点を $(x_0, y_0)$、 右端の長方形の左上の頂点を $(x_{n-1}, y_{n-1})$ とすると、円周率 $\pi $ は、以下の式で求められる。
\begin{eqnarray}
\pi &=& 4 \cdot S_0 \\
&=& 4 \cdot (w \cdot y_0 + w \cdot y_1 + \dotsb + w \cdot y_{n-1}) \\
&=& 4 \cdot w \cdot (y_0 + y_1 + \dotsb + y_{n-1})\\
\end{eqnarray}
あとは $x$ から $y$ を導く方法がわかればいい。$X$ 軸と $Y$ 軸は直角に交わるので、3つの座標 $(0, 0), (x_i, 0), (x_i, y_i)$ を結ぶと、各辺の長さが $x_i,\ y_i,\ r$ の直角三角形ができる。辺の長さの関係は、三平方の定理により、$x^2 + y^2 = r^2$ となる。$r = 1$ とし、式を変形すればいい。
\begin{eqnarray}
x^2 + y^2 &=& r^2 \\
x^2 + y^2 &=& 1 \\
y^2 &=& 1 - x^2 \\
y &=& \sqrt{1 - x ^ 2}
\end{eqnarray}
その1 Elixir:シングルプロセスで計算
円周率の求め方がわかったところで、早速、Elixir でプログラミングしよう。今回使ったプログラムは、ここに 置いてある。
ベースとなる Elixir プロジェクトは、以下のように Elixir の Mix で作成した。
mix new elixir_rust_interop_demo
すると、こんな構成になる。
elixir_rust_interop_demo
├── README.md
├── config
│ └── config.exs
├── lib
│ └── elixir_rust_interop_demo.exs <-- pi.ex にリネームして、プログラムを書いていく
├── mix.exs
└── test
lib
ディレクトリーに Elixir のソースファイル elixir_rust_interop_demo.ex
があるので、それを pi.ex
にリネームし、以下のプログラムを書き込む。
defmodule Pi do
@spec calc_pi(n :: non_neg_integer) :: {:ok, pi :: float}
def calc_pi(n) do
w = 1.0 / n
s0 = 0.0
s1 = Enum.reduce(0..(n - 1), s0, fn(i, s) ->
x = i * w
s + :math.sqrt(1.0 - x * x)
end)
{:ok, 4.0 * w * s1}
end
end
早速実行してみよう。
% iex -S mix
Erlang/OTP 18 [erts-7.2.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false]
Compiled lib/pi.ex
Generated elixir_rust_interop_demo app
Consolidated List.Chars
...
Interactive Elixir (1.2.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> l Pi
{:module, Pi}
iex(2)> :timer.tc(fn() -> Pi.calc_pi(1_000_000) end)
{112146, {:ok, 3.141594652413976}}
iex(3)> :timer.tc(fn() -> Pi.calc_pi(1_000_000_000) end)
{113853091, {:ok, 3.1415926555901215}}
100万個に分割した場合は、計算に 112,146 マイクロ秒(0.11秒)要し、小数点以下5桁程度の精度が得られた。10億個に分割した場合は、計算に約1分54秒要し、小数点以下8桁程度の精度が得られた。なお、Elixir の float 型は、64ビットのIEEE 754浮動小数点数なので、一般的なプログラミング言語の double 型に相当する。
ループを効率化する
このプログラムでは10億回のループを回すために Range 0..(n - 1)
に Enum.reduce/3
を適用したが、Elixir 1.2 の Range の実装を見ると、ループをひたすら回すような用途では効率が悪そうだ。以下のように、より効率がいい自作の高階関数 for_each/4
で置き換えてみた。
@spec calc_pi(n :: non_neg_integer) :: {:ok, pi :: float}
def calc_pi(n) do
w = 1.0 / n
s0 = 0.0
s1 = for_each(0, n, s0, fn(i, s) -> # <- ここを変更した
x = i * w
s + :math.sqrt(1.0 - x * x)
end)
{:ok, 4.0 * w * s1}
end
# この高階関数を追加した
@spec for_each(index :: integer,
max :: integer,
init_acc :: term,
((i :: integer, acc0 :: term) -> acc1 :: term))
:: final_acc :: term
defp for_each(max, max, acc, _fun) do
acc
end
defp for_each(i, max, acc, fun) do
for_each(i + 1, max, fun.(i, acc), fun)
end
実行してみよう。
iex(1)> l Pi
{:module, Pi}
iex(2)> :timer.tc(fn() -> Pi.calc_pi(1_000_000_000) end)
{77341972, {:ok, 3.1415926555901215}}
10億分割時の計算時間は約77.3秒となり、先ほどと比べて 1.5 倍ほど高速化した。
その2 Elixir:マルチプロセスで計算
せっかくマシンが8個の論理コアを搭載しているので、全部使ってみよう。複数の軽量プロセスを起動して、面積を分割して求めればいい。Elixir なら直感的にプログラミングできる。
まず、calc_pi/1
を元に、四分円の一部の範囲だけの面積を求めるプログラム calc_pi_range/3
を追加する。この関数は、対象領域を n
等分して、offset
番目から count
個分の短形について、その面積を算出する。
@spec calc_pi_range(n :: non_neg_integer,
offset :: non_neg_integer,
count :: non_neg_integer) :: pi :: float
def calc_pi_range(n, offset, count) do
w = 1.0 / n
s0 = 0.0
s1 = for_each(offset, offset + count, s0, fn(i, s) ->
x = i * w
s + :math.sqrt(1.0 - x * x)
end)
4.0 * w * s1
end
次に calc_pi_range/3
を呼び出す関数 calc_pi_parallel/2
を追加する。この関数は、num_procs
個のプロセスを立ち上げ、calc_pi_range/3
を parallel に実行する。
@max_procs 1024
@timeout 60_000 # 1 minute
@spec calc_pi_parallel(n :: non_neg_integer,
num_procs :: non_neg_integer) :: {:ok, pi :: float}
| {:error, term()}
# num_process の値をチェックし、範囲外ならエラーを返す。
def calc_pi_parallel(_n, num_procs) when num_procs <= 0 or num_procs > @max_procs do
{:error,
'Invalid num_procs #{num_procs}. It must be > 0 and <= #{@max_procs}'}
end
def calc_pi_parallel(n, num_procs) when rem(n, num_procs) != 0 do
{:error, 'n #{n} must be a multiple of num_procs #{num_procs}'}
end
# num_process の値が範囲内なので、calc_pi_range/3 を parallel に実行する。
def calc_pi_parallel(n, num_procs) do
len = div(n, num_procs)
pi = 0..(num_procs - 1)
|> Enum.map(&(Task.async(fn() -> Pi.calc_pi_range(n, len * &1, len) end)))
|> Enum.map(&(Task.await(&1, @timeout)))
|> Enum.sum
{:ok, pi}
end
プロセスを立ち上げてから、結果を返すまでのコードを順に見てみよう。まず Task.async/1
でプロセスを立ち上げ、
pi = 0..(num_procs - 1)
|> Enum.map(&(Task.async(fn() -> Pi.calc_pi_range(n, len * &1, len) end)))
Task.await/2
で結果を集め、
|> Enum.map(&(Task.await(&1, @timeout)))
Enum.sum/1
で合計する。
|> Enum.sum
10億分割、10プロセスでの実行結果は以下の通り。
iex(2)> :timer.tc(fn() -> Pi.calc_pi_parallel(1_000_000_000, 10) end)
{29298379, {:ok, 3.141592655589816}}
約29.3秒で、シングルプロセス時の約2.6倍の速度となった。論理コアが8個とはいえ、実コアは4個なので、このくらいが現実的なのだろうか。top
コマンドでは以下のように表示された。
PID USERNAME THR PRI NICE SIZE RES STATE C TIME WCPU COMMAND
2921 tatsuya 41 20 0 268M 72688K uwait 2 2:14 710.87% beam.smp
その後、プロセス数を20、40と増やしてみたが、結果は同じだった。
その3 Elixir:HiPE でネイティブコード化
Elixir のまま、もう少し頑張ってみよう。といっても頑張るのはコンパイラーであって、私たちではない。コードの変更は不要だ。
HiPE(High Performance Erlang)を使って、beamファイル内のコンパイル済みコードを、仮想マシンのバイトコードから、ネイティブコードに変換する。なお、この機能を使うためには、Erlang/OTP のビルド時に、--enable-hipe
オプションを与えておかないといけない。また、--enable-native-libs
も指定すると、Erlang/OTP のライブラリーの大半(?)が HiPE でコンパイルされる。
では、iex を立ち上げたまま、該当モジュールのみ HiPE 化しよう。別のターミナルから、以下のコマンドを実行する。
cd elixir_rust_interop_demo
ERL_COMPILER_OPTIONS="[native, {hipe, [o3]}]" elixirc -o _build/dev/lib/elixir_rust_interop_demo/ebin/ lib/pi.ex
iex からモジュールをリロードして、再度実行する。
iex(3)> l Pi
{:module, Pi}
iex(4)> :code.is_module_native(Pi)
true
iex(5)> :timer.tc(fn() -> Pi.calc_pi_parallel(1_000_000_000, 10) end)
{15226457, {:ok, 3.141592655589816}}
実行時間は約15秒ということで、HiPE 前と比べると約1.9倍の実行速度になった。このように計算が重い(CPU bound な)プログラムでは、HiPE の効果が大きくでる。
その4 Elixir から Rust のマルチスレッドプログラムを呼び出す
ではいよいよ Rust で書いてみる。まず、Rust 関連のファイルを格納する場所を作ろう。
rust_src と priv ディレクトリーを追加する
Elixir や Erlang のプロジェクトで C言語のソースコードを格納する時は、c_src
というディレクトリーを使うのが一般的だ。それにならって、rust_src
ディレクトリーを使うことにする。
Rust のプロジェクトを Cargo で作成する。elixir_rust_interop_demo
ディレクトリー内で、以下のコマンドを実行する。
cargo new --name pi rust_src
さらに、Rust プログラムのビルドで作成される成果物(動的ロードの共有ライブラリー)を格納するために、priv
ディレクトリを用意する。
mkdir priv
touch priv/.gitsave
この後、ソースコードを追加していくと、以下のような構成になるはずだ。
elixir_rust_interop_demo
├── README.md
├── _build
│ └── ...
├── config
│ └── config.exs
├── lib
│ ├── pi.ex <-- いままで書いた Elixir モジュールのソースファイル
│ └── pi_nif.ex <-- これから追加する Elixir モジュールのソースファイル
├── mix.exs
├── priv
│ └── libpi_nif.so <-- コンパイル済みの Rust プログラムが格納された、共有ライブラリー
├── rust_src
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── src
│ │ ├── lib.rs <-- これから追加する Rust プログラムのソースファイル
│ │ └── pi.rs <-- これから追加する Rust プログラムのソースファイル
│ └── target
│ └── ...
└── test
円周率近似プログラムの移植
まずは、Elixir のマルチプロセス版プログラムを Rust に移植する。rust_src/src/pi.rs
ファイルに書いていくが、これは簡単な作業だ。というのは、Rust は Elixir と同様にモダンな言語で、しかも関数型言語の影響を強く受けているからだ。ほとんど同じ感覚で書ける。
calc_pi_range()
は Elixir とほとんど同じ。
fn calc_pi_range(n: u32, offset: u32, count: u32) -> f64 {
let w = 1.0 / (n as f64);
let mut s = 0.0;
for i in offset..(offset + count) {
let x = (i as f64) * w;
s += (1.0 - x * x).sqrt();
}
4.0 * w * s
}
もう一方の calc_pi_parallel()
も、かなり似ている。
use std::thread;
const MAX_THREADS: u32 = 64;
#[allow(dead_code)]
pub fn calc_pi_parallel(n: u32, num_threads: u32) -> Result<f64, String> {
if num_threads <= 0 || num_threads > MAX_THREADS {
Err(format!("Invalid num_threads {}. It must be > 0 and <= {}",
num_threads, MAX_THREADS))
} else if n % num_threads != 0 {
Err(format!("n {} must be a multiple of num_threads {}",
n, num_threads))
} else {
let len = n / num_threads;
let handles: Vec<_> = (0..num_threads).map(|i| {
thread::spawn(move || {
calc_pi_range(n, len * i, len)
})
}).collect();
let results = handles.into_iter().map(|h| { h.join().unwrap() });
// std::iter::Iterator の sum() は Rust 1.5 では unstable に
// 指定されており使えない。代わりに fold() を使う。
let pi: f64 = results.into_iter().fold(0.0, |acc, p| { acc + p });
Ok(pi)
}
}
マルチスレッドの実行過程について、Eixir と対比させてみよう。
まず、num_threads
個のプロセスを立ち上げ、calc_pi_range/3
を parallel に実行する部分。どちらもシーケンス型に map を適用し、Task.async/1
または thread::spawn()
でスレッドを起動している。
pi = 0..(num_procs - 1)
|> Enum.map(&(Task.async(fn() -> Pi.calc_pi_range(n, len * &1, len) end)))
let handles: Vec<_> = (0..num_threads).map(|i| {
thread::spawn(move || {
calc_pi_range(n, len * i, len)
})
}).collect();
なお、Rust では軽量プロセス(グリーン・スレッド)ではなく、OSが提供するネイティブ・スレッドを使用しているので、Elixir のように数百万個のプロセスを稼働させるといった芸当はできない。今回は重い計算処理なので、論理コア数を少し超えるくらいのスレッドを走らせれば十分なので、これで問題ない。
ちなみに、Rust の無名関数ブロックは、Ruby の文法を参考にしたそうだ。
結果を集める部分。どちらもスレッドのハンドルに map を適用。Task.await/2
と join()
が対応している。
|> Enum.map(&(Task.await(&1, @timeout)))
let results = handles.into_iter().map(|h| { h.join().unwrap() });
合計する部分。
|> Enum.sum
// std::iter::Iterator の sum() は Rust 1.5 では unstable に
// 指定されており使えない。代わりに fold() を使う。
let pi: f64 = results.into_iter().fold(0.0, |acc, p| { acc + p });
Rust 1.5 では、標準ライブラリの仕様と実装の安定化の真っ最中で、非安定(unstable)に指定された sum()
は、安定版の Rust では使用できない。今回は Elixir の reduce/3
に相当する fold()
を使ったが、安定化が進めば、ここは sum()
で置き換えられるようになる。
calc_pi_parallel()
は結果を Result<f64, String>
型で返す。この型は失敗するかもしれない処理を暗示しており、成功時は Ok<f64>
が、失敗時は Err(String)
が返る。Haskell や Scala の Either
型と同じコンセプトだ。
Rust などのネイティブ関数を Elixir から呼び出す3つの方法
Elixir や Erlang モジュールから、Rust や C 言語などで書かれたネイティブプログラムを呼び出には、3つの方法かある。
- Port Driver: 指定したプログラムを Erlang VM とは別のプロセスとして立ち上げ、標準入力と標準出力を介して情報をやり取りする方法。たとえば、ビルドツールの Mix や Rebar が外部コマンドを実行するときにはこの方法を使っている。
- Port Linked-In Driver: 共有ライブラリーを Erlang VM に読み込んで実行する方法のひとつ。古くからあり十分安定している。OTPのネットワークやファイルIOのドライバーがこの方式で実装されている。APIが若干複雑だが、非同期性が高い操作を実装しやすい。
- NIF (Native Implemented Function): 共有ライブラリーを Erlang VM に読み込んで、実行するもうひとつの方法。APIが比較的単純で、ネイティブ関数を同期式に呼び出す時に便利。
Port Driver は通信を介すので性能面では不利だが、外部コマンドがクラッシュしても Erlang VM に影響がないのが利点だ。
他の2つの方法は効率がいい反面、ネイティブプログラムがクラッシュすると、Erlang VM までクラッシュしてしまうので、実装に細心の注意を払う必要がある。またどちらも、ネイティブ関数が呼びだされてから 1ms 以内に Erlang VM に制御を戻さないといけないという制約がある。これについては、いくつかの対応法があるのだが、それは後日、別の記事にまとめようと思う3。
今回は NIF を使って実装する。1ms 以内に返答制約の対策として、Erlang/OTP 17.0 から実験的に導入された「dirty scheduler」の機能を使う。この方法だと、1ms の制約を無視しても、一応、問題なく動くものができる。dirty scheduler を有効にするには、Erlang/OTP のビルド時に --enable-dirty-schedulers
を指定する。
Elixir 側で NIF を呼び出すモジュールを定義する
まず、Elixir 側でいままで Pi
というモジュールを使っていたのだが、NIF 用に新しいモジュール PiNif
を用意する。というのは、HiPE と NIF の相性が悪く、HiPE でコンパイルしたモジュールで、NIF の共有ライブラリーをロードしようとすると、エラーになってしまうからだ4。HiPE 用と、NIF 用に Elixir のモジュールを分ける必要がある。
defmodule PiNif do
@on_load :init
@mod PiNif
@lib_name 'pi_nif' # char list
@spec calc_pi_parallel(n :: non_neg_integer,
num_threads :: non_neg_integer)
:: {:ok, pi :: float} | {:error, term()} | no_return
def calc_pi_parallel(_n, _num_threads) do
:erlang.nif_error({:nif_not_loaded, @mod})
end
def init() do
priv_dir = case :code.priv_dir(@app) do
dir when is_list(dir) ->
dir
{:error, :bad_name} ->
case :code.which(@mod) do
:bad_name ->
'./priv'
:non_existing ->
'./priv'
dir when is_list(dir) ->
:filename.join([:filename.dirname(dir), '../priv'])
end
end
so_name = :filename.join(priv_dir, 'lib' ++ @lib_name)
:erlang.load_nif(so_name, 0)
end
end
まず、@on_load
で、モジュールのロード時に実行される関数を指定する。ここでは、ごく一般的な名前 init/0
とした。init/0
は :erlang.load_nif/2
を使って、Rust で書かれた libpi_nif.so
という共有ライブラリーをロードする。
calc_pi_parallel/2
は、この後 Rust 側で、これに対応する関数を書くので、共有ライブラリーがロードされると、この関数の内容が共有ライブラリーのそれで置き換えられる。もしうまくロードできなかった時はエラーを返したいので、上記のように :erlang.nif_error/1
を呼ぶようにした。
NIF の Glue 関数を実装する
glue 関数(糊付け関数)というのは、いま頭に浮かんだ言葉なので、一般に通用する言葉かどうかはわからない。が、ここでは、Elixir のモジュールに書いた関数と、Rust の関数をくっつけるための関数をイメージしている。これは Rust で書く。NIF の場合でも、Port Linked-In Driver の場合でも、それぞれのルールに沿った glue 関数を書かなければ動かない。
ここは一通り書き方がわかるまで、面倒な部分だ。NIF の API(C言語の関数)や、Rust で C言語の関数を呼び出す時の作法をよく理解していないうちは、コンパイルエラーが多発する。逆に、いったんコンパイルを通してしまえば、実行時にエラーが出ることはまずないのが Rust のいいところだ。
まず、Cargo.toml
に以下の内容を追加する。このファイルは、Mix の mix.exs
に相当するプロジェクトの設定ファイルだ。
[lib]
name = "pi_nif"
crate-type = ["dylib"]
[dependencies]
ruster_unsafe = { git = "https://github.com/tatsuya6502/ruster_unsafe/", rev = "nif-2.9-unmerged" }
libc = ">=0.2.4"
lib セクションには、pi_nif
という名前の動的ロードライブラリー(dylib)を生成することを指定した。
dependencies セクションでは、ruster_unsafe というクレート(Rust のパッケージのこと)と、libc クレートを使うことを指定した。ruster_unsafe は "rust"-"er"(Rust + Erlang の意味)という名前の通り、Rust から Erlang の NIF API が使えるようにするためのものだ。本家にまだ取り込まれていない修正があるので、今は私のフォークを指定しておく。libc は Rust の関数と C言語の関数の相互呼び出しに必要だ。
次に、rust_src/src/lib.rs
ファイルに glue 関数を書いていく。まず始めに ruster_unsafe が用意した NIF の初期化マクロを使う。
#[macro_use]
extern crate ruster_unsafe;
use ruster_unsafe::*;
nif_init!(b"Elixir.PiNif\0",
Some(load), // on load (必須)
None, // on reload
Some(upgrade), // on upgrade (必須)
None, // on unload
nif!(b"calc_pi_parallel\0",
2,
calc_pi_parallel,
ERL_NIF_DIRTY_JOB_CPU_BOUND)
);
b"Elixir.PiNif\0"
が、NIF のモジュール名。この名前と、呼び出し側の Elixir のモジュール名は同じでないといけない。Elixir のモジュール名は、defmodule
で指定した名前の前に Elixir.
が付くので、この名前になっている。C言語の文字列なので null文字 \0
を最後につける。
第2引数から第5引数に指定した関数は、モジュールのロード時などに呼ばれる ので、初期化や、後片付けが必要な時は実装する 。今回はロード時に load()
関数を呼ぶようにして、他は **2015年1月18日修正:**さらに、アップグレード時は None
とすることで、なにも呼ばれないようにした。upgrade()
関数を呼ぶようにした。他は None
とすることで、なにも呼ばれないようにした。(第2引数 on load と第4引数 on upgrade は Option 型にもかかわらず、必須 でした)
その次の nif!(b"calc_pi_parallel\0"
が、Elixir の calc_pi_parallel/2
に対応する Rust 関数の情報になる。
- Elixir 側の関数名が
"calc_pi_parallel\0"
(C言語の文字列で表現)で、arity が2
- 対応する Rust 関数が
calc_pi_parallel
- CPU bound 用の dirty scheduler で実行する(
ERL_NIF_DIRTY_JOB_CPU_BOUND
)
もし、他にも関数があるのなら nif!(...)
を続けて書いていけばいい。
次に、load()
の実装。calc_pi_parallel()
が戻り値を返す時に使う :ok
アトムと :error
アトムを初期化する。なお、NIF API の関数は、名前が enif_
で始まる。
extern crate libc;
use libc::c_double;
static mut ok_atom: ERL_NIF_TERM = 0 as ERL_NIF_TERM;
static mut error_atom: ERL_NIF_TERM = 0 as ERL_NIF_TERM;
/// static な変数にアトムを設定する。
extern "C" fn load(env: *mut ErlNifEnv,
_priv_data: *mut *mut c_void,
_load_info: ERL_NIF_TERM)-> c_int {
unsafe {
ok_atom = enif_make_atom(env, b"ok\0" as *const u8);
error_atom = enif_make_atom(env, b"error\0" as *const u8)
}
0
}
upgrade()
は何もせず、単に成功を表す 0
を返すようにした。
extern "C" fn upgrade(_env: *mut ErlNifEnv,
_priv_data: *mut *mut c_void,
_old_priv_data: *mut *mut c_void,
_load_info: ERL_NIF_TERM)-> c_int {
0
}
続いて calc_pi_parallel()
の実装。まず、mod pi;
で、pi モジュール(rust_src/src/pi.rs
)に書いた円周率計算の public 関数を使えるようにする。
glue 関数には calc_pi_parallel()
のように、3つの引数が渡される。env
が Erlang の VM 環境(ランタイム)を表す構造体、argc
が引数の数、args
が引数の入った構造体だ。戻り値は Erlang のデータを表す C言語の構造体で、calc_pi_parallel()
では、引数の値によって以下のどれかになる。
{:ok, piの近似値(float 型)}
{:error, エラーの内容を示す文字列}
BadArgumentError
use std::mem::uninitialized;
mod pi;
/// Elixir: @spec calc_pi_parallel(n :: non_neg_integer,
/// num_threads :: non_neg_integer)
/// :: {:ok, pi :: float} | {:error, term()} | no_return
extern "C" fn calc_pi_parallel(env: *mut ErlNifEnv,
argc: c_int,
args: *const ERL_NIF_TERM) -> ERL_NIF_TERM {
let mut n: c_int = unsafe { uninitialized() };
let mut num_threads: c_int = unsafe { uninitialized() };
if argc != 2
|| 0 == unsafe { enif_get_int(env, *args, &mut n) }
|| 0 == unsafe { enif_get_int(env, *args.offset(1), &mut num_threads) }
|| n <= 0 {
return unsafe { enif_make_badarg(env) };
}
match pi::calc_pi_parallel(n as u32, num_threads as u32) {
Ok(pi) =>
make_ok_result(env, unsafe { &enif_make_double(env, pi as c_double) }),
Err(reason) =>
make_error_result(env, &reason),
}
}
最初の if
式までで引数を受け取り、引数の型などが妥当かチェックをしている。
NIF の C API とやりとりをするため、unsafe { ... }
ブロックが何度も使われている。Rust のコンパイラーが安全性を検証できるのは、Rust で書かれた部分のみなので、外部の関数の呼び出しや、C言語の生ポインターが示す番地へのアクセスなどは、対象外となる。もし unsafe
で囲まないと、安全性が確認できないため、コンパイルエラーとなってしまう。
unsafe
で囲むとコンパイルできるようになるが、囲んだ部分のコードの安全性については、開発者自身が確認しなければならない。
最後の match
式では、piモジュールの方の calc_pi_parallel()
を呼び出し、結果をパターンマッチで取り出している。もし Ok(pi)
なら、make_ok_result()
を呼んで、{:ok, pi}
のタプルを作って返す。もし Err(reason)
なら make_error_result()
を呼んで、{:error, result}
のタプルを作って返す。
make_ok_result()
と make_error_result()
の実装は以下の通り。雰囲気はつかめるだろうか。
fn make_ok_result(env: *mut ErlNifEnv, result: *const ERL_NIF_TERM) -> ERL_NIF_TERM {
let tuple_elements = unsafe { [ok_atom, *result] };
unsafe { enif_make_tuple_from_array(env, tuple_elements.as_ptr(), 2) }
}
fn make_error_result(env: *mut ErlNifEnv, reason: &str) -> ERL_NIF_TERM {
let reason_str = unsafe { enif_make_string_len(env, reason.as_ptr(), reason.len(),
ErlNifCharEncoding::ERL_NIF_LATIN1) };
let tuple_elements = [unsafe { error_atom }, reason_str];
unsafe { enif_make_tuple_from_array(env, tuple_elements.as_ptr(), 2) }
}
mix compile で cargo build --release を呼び出す
では、Rust による共有ライブラリーをビルドしよう。rust_src
ディレクトリーで、cargo build --release
としてもいいが、ここでは、Mix から、Rust コードと Elixir コードを一括してビルドできるようにする。
mix.exs
を以下のように変更する。
defmodule Pi.Mixfile do
use Mix.Project
def project do
[app: :elixir_rust_interop_demo,
version: "0.0.1",
elixir: "~> 1.2",
compilers: [:cargo, :elixir, :app], # <-- この行を追加した。
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
deps: deps]
end
def application do
[applications: [:logger]]
end
defp deps do
[]
end
end
####################
# Rust Cargo Tasks #
####################
defmodule Mix.Tasks.Compile.Cargo do # <-- このモジュールを追加した。
@shortdoc "Compiles helper in rust_src"
def run(_) do
case System.cmd("cargo", ["build", "--release"],
cd: "rust_src",
stderr_to_stdout: true) do
{result, 0} ->
if result != "" do
Mix.shell.info result
end
# @TODO: Skip coping the file if it is up-to-date.
case System.cmd("cp", ["-p", "rust_src/target/release/libpi_nif.so", "priv"],
stderr_to_stdout: true) do
{"", 0} ->
:ok
{result, 0} ->
Mix.shell.info result
:ok
{result, _error_code} ->
Mix.shell.error result
raise "copying libpi_nif.so failed"
end
{result, _error_code} ->
Mix.shell.error result
raise "cargo build --release failed."
end
end
end
defmodule Mix.Tasks.Clean.Cargo do # <-- このモジュールを追加した。
@shortdoc "Cleans helper in rust_src"
def run(_) do
case System.cmd("cargo", ["clean"],
cd: "rust_src",
stderr_to_stdout: true) do
{result, 0} ->
Mix.shell.info result
:ok
{result, _error_code} ->
Mix.shell.error result
:ok
end
end
end
これで、mix compile
または iex -S mix
とした時に、Rust 側も cargo build --release
でビルドされる。できあがった共有ライブラリー libpi_nif.so
は、priv
ディレクトリーへコピーされる。
試しにビルドしてみよう。
mix compile
Compiling ruster_unsafe v0.2.0 (https://github.com/tatsuya6502/ruster_unsafe/?rev=nif-2.9-unmerged#250957b5)
Compiling libc v0.2.4
Compiling pi v0.1.0 (file:///usr/home/tatsuya/workhub/dev/elixir_rust_interop_demo/rust_src)
Compiled lib/pi_nif.ex
Compiled lib/pi.ex
Generated elixir_rust_interop_demo app
Consolidated List.Chars
...
実行する
実は私は Rust の超初心者なので5、C関数とのやり取りの作法がよくわからず、glue 関数のコンパイルを通すのに、ずいぶん苦労してしまった。いやー、長かった。でも、この苦労は無駄にならないだろう。
では実行してみよう。iex を立ち上げる。
% iex -S mix
Erlang/OTP 18 [erts-7.2.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false]
Interactive Elixir (1.2.0) - press Ctrl+C to exit (type h() ENTER for help)
まず、dirty scheduler が有効になっていることを確認する。iex の立ち上げ時に [ds:8:8:10]
のように表示されれば OK だ。これは、左から順に、dirty CPU scheduler の最大本数、オンライン中のdirty CPU schedulerの本数、dirty IO scheduler の本数を表している。
モジュールをロードして実行しよう。
iex(1)> l PiNif
{:module, PiNif}
iex(2)> :timer.tc(fn() -> PiNif.calc_pi_parallel(1_000_000_000, 10) end)
{1262147, {:ok, 3.141592655589816}}
おぉ、速いっ! 約1.3秒で終了。Elixir で HiPE を使用した時と比べると、約12.1倍の速度となった。小数点以下の全15桁が Elixir の計算結果と一致しているので、ちゃんと計算しているようだ
ちなみに、コードの掲載は省略したが、シングルスレッド版もある。
iex(3)> :timer.tc(fn() -> PiNif.calc_pi(1_000_000_000) end)
{4134014, {:ok, 3.1415926555901215}}
これにより、マルチスレッド版はシングルスレッド版よりも、約3.28倍速いことがわかった。
SIMD 命令にさらに期待
前にも書いたように、現状は Rust の関数を SIMD 命令への最適化がされてない状態で実行している。仮に最適化がされたなら、このマシンのプロセッサー(AVX 命令に対応)なら、4組の64ビット浮動小数点が、1度に計算できるようになる6。このプログラムの場合、sqrt 計算を繰り返すので、SIMD の効果が大きそうだ。後日、挑戦してみたい。
測定結果
その後、FreeBSD で再度測定した。それぞれの関数を3回ずつ実行し、その中央値(2番目に速かった値)を採用した。
\pi = 3.14159\ 26535\ 89793\ \dotsb
# | 言語 | proc/thr数 | プログラム | 計算結果 | 所要時間(マイクロ秒) | 相対速度 | 相対速度 |
---|---|---|---|---|---|---|---|
1 | Elixir | 1 | Enum.reduce/3 | 3.1415926555901215 | 113,853,091 | 0.68 | 0.13 |
2 | 1 | (HiPE) | 3.1415926555901215 | 111,940,965 | 0.70 | 0.14 | |
3 | 1 | for_each/4 | 3.1415926555901215 | 77,914,595 | 1.00 | 0.20 | |
4 | 1 | (HiPE) | 3.1415926555901215 | 45,786,441 | 1.70 | 0.33 | |
5 | 10 | parallel | 3.1415926555898160 | 29,319,387 | 2.66 | 0.52 | |
6 | 10 | (HiPE) | 3.1415926555898160 | 15,226,457 | 5.12 | 1.00 | |
7 | Rust | 1 | NIF | 3.1415926555901215 | 4,134,014 | 18.85 | 3.68 |
8 | 10 | parallel NIF | 3.1415926555898160 | 1,209,160 | 64.44 | 12.60 |
まとめ
- Elixir は堅牢でスケーラブルなアプリケーションの開発生産性を高める動的型付け言語
- Rust は安全性とスピードにフォーカスした、システムプログラミング向けの静的型付け言語
- どちらの言語もモダンな言語の特徴を取り込んでおり、よく似たスタイルでプログラムが書ける
- どちらの言語もマルチコアプロセッサに適したプログラムが簡単に書ける
- 2つの言語の使いどころを見極め、組み合わせることで、幸せになれそう
-
RustのFAQによると、Servoのソースコード行数は現時点で3万行強とのこと。ちなみにRustのコンパイラーはRust自身で書かれており、その行数は現時点で6万行強。バックエンドとしてLLVMを使用している ↩
-
有効にするには、「compiler target triple」というコンパイラーのビルドオプションを変更し、Rustコンパイラーとライブラリ一式をソースコードからビルドすればいいようだ。SIMD のサポートとは無関係だが、ビルドオプションの変更については こちらの記事 で詳しく解説されている。後日、挑戦してみたい ↩
-
HiPE の開発終了後しばらくしてからNIFが導入されたため、HiPEのbeamローダーがNIFに対応してないのが原因のようだ。HiPEの開発に携わったチームのメンバーはみなEricsson社を退社しているため、VMチームにHiPEに詳しい人がいないらしい。ただ、HiPEの設計者は、erang-userメーリングリストで、いつも質問に回答されているようだ。 ↩
-
私が Rust を知ったのは、@voluntas さんの、「私的な 2015 年技術的な振り返り」で 紹介されていたのがきっかけだった。ありがとうございます。Erlang と一緒に使える、こんな言語が欲しかったのです ↩
-
Rust 1.5.0 での SIMD 対応状況ははっきりわからないのだが、今のところ自動最適化は SSE までなのかもしれない。simd クレートの方は、フォークして AVX 命令に対応させた人もいるので、こちらも試してみたい。 ↩