連記事目次
- Ruby/Rust 連携 (1) 目的
- Ruby/Rust 連携 (2) 手段
- Ruby/Rust 連携 (3) FFI で数値計算
- Ruby/Rust 連携 (4) Rutie で数値計算①
- Ruby/Rust 連携 (5) Rutie で数値計算② ベジエ
- Ruby/Rust 連携 (6) 形態素の抽出
- Ruby/Rust 連携 (7) インストール時ビルドの Rust 拡張 gem を作る
はじめに
FFI(Foreign Function Interface)を使って,簡単な数値計算を行う Rust の関数を Ruby から呼ぶ,ということをやってみよう。
既にこのようなテーマの記事は複数存在するので N 番煎じなのだが。
なお,
- macOS 10.13.6(High Sierra)
- Rust 1.46.0
- Ruby 2.7.1
を使用した。
題材
どうせならフィボナッチ数より実用性のあるものが作りたい。
よし,アレにしよう。
Math.#hypot の 3 変数版だ。3 次元版と言ってもいい。
Math.hypot
という奇妙な名前のモジュール関数は,要するに
\sqrt{ x^2 + y^2 }
を計算してくれるもの。
直角三角形の直交する二辺の長さ $x$, $y$ から斜辺(hypotenuse)の長さを計算するのでこの名がある。
p Math.hypot(3, 4) # => 5.0
これの 3 変数版は,要するに
\sqrt{ x^2 + y^2 + z^2 }
を計算する関数というわけだ。
動機
どうして Math.hypot
の 3 変数版が欲しいか,という話をする。記事の主題と関係ないので,次節に飛んでもらって構わない。
この関数は,平面上の 2 座標が与えられたとき,その 2 点間の距離を得るのに使える。
つまり,
p1 = [1, 3]
p2 = [2, -4]
distance = Math.hypot(p1[0] - p2[0], p1[1] - p2[1])
というように。
まあ,Vector を使えば
require "matrix"
p1 = Vector[1, 3]
p2 = Vector[2, -4]
distance = (p1 - p2).norm
でいけるんだけどもね。
話が逸れた。
では 3 次元空間中の 2 点間の距離は,となると,当然 hypot
の 3 次元版が欲しくなるわけだ。
いやもちろん,Vector
を使えば上記のように何次元だろうが簡単に書けるわけだが,速度その他の理由で Vector
を使いたくない場合もあるだろう,と。
3 変数版は
def Math.hypot3(x, y, z)
Math.sqrt(x * x + y * y + z * z)
end
と,簡単に定義できる。
ちなみに x ** 2
とせずに x * x
とした理由は,前者がたいへん遅いからである1。
しかし,上記のメソッドは,乗算 3 回,加算 2 回,sqrt
1 回あり,これらは全部メソッド呼び出しだから,計 6 回もメソッドを呼んでいる。
こういう式は,もしかすると Rust や C で書き直すと速くなるのかもしれない。
実装:Rust 側
Rust をよく知らない人でも再現できるように書くね。
ただし,Rust のインストールは済んでいるとする。
プロジェクト作成
まずターミナルで
cargo new my_ffi_math --lib
とやって,Rust のプロジェクトを一つ作る。
my_ffi_math はプロジェクト名。
オプションの --lib
は,「ライブラリークレートを作る」という指定。
クレートというのはコンパイルの単位なのだが,
- バイナリークレート:実行ファイルを作る
- ライブラリークレート:ライブラリーを作る
という二種類がある。
Cargo.toml の編集
プロジェクトのルートに Cargo.toml というファイルがある。ここに,プロジェクト全体に関わるいろいろな設定などが書かれる。
このファイルの末尾に
[lib]
crate-type = ["cdylib"]
と追記する。
これの意味は筆者にはよく分かってない。
関数の作成
src/lib.rs というファイルがあるはずだ。
ここには,テストコードの雛形が書かれているが2,これは削除してしまって構わない。
そして,
#[no_mangle]
pub extern fn hypot3(x: f64, y: f64, z: f64) -> f64 {
(x * x + y * y + z * z).sqrt()
}
と書く。
fn
が関数の定義を表すキーワード。pub
と extern
はそれに対するオマケで,ええと,ちゃんと説明できないっす。pub
は「この関数を外部に公開するぜ」というようなことなんだろうけど。
f64
は 64 bit 浮動小数点数という型を表していて,これが FFI を通して Ruby の Float に対応する。
->
は関数の返り値の型を表している。
関数の中身は,見ればまあなんとなく分かる。
関数定義の前に付いている
#[no_mangle]
が気になるね。
筆者にもよく分かってないけど,これを書いておかないと,せっかく hypot3
という名前で関数を定義したのに,コンパイル後にその名前では参照できなくなる,ということらしい。
Rust 側の実装は以上でオシマイ。
コンパイル
プロジェクトのルートディレクトリーで
cargo build --release
とやると,コンパイルされる。
build
というのはコンパイルをカッコよく言ったもの(? いや,たぶん違う)
ビルドは,デバッグ用とリリース(本番)用の二通り可能で,--release
は文字通りリリース用にビルドしろ,ということ。
デバッグ用だと実行速度が遅い。
コンパイルして出来たものは target/release/libmy_ffi_math.dylib というパスにあるはずだ。
あ,いや,ファイル名の拡張子はイロイロなんだよな。macOS 上でやると .dylib
になったけど,Windows とかでは違うはず3。
Ruby 側で必要になるのはこのファイルだけ。
ファイル名のベース名(拡張子を除いた部分)は,今の場合,プロジェクト名の頭に lib
を付けたものになっている。
もし,Cargo.toml の [lib]
のところに name
を追記して
[lib]
name = "hoge"
crate-type = ["cdylib"]
のようにすれば,ファイル名は libhoge.dylib
のようになるはず。
実装:Ruby 側
Ruby 側では ffi という gem を使う(fiddle を使う方法もある)。
以下のコードでは簡単に
require "ffi"
とやるが,もちろん Gemfile で管理するなら
gem "ffi", "~> 1.13"
とでも書いておいて,スクリプトで
require "bundler"
Bundler.require
などとすればよい。
また,Ruby のコードはとりあえず Rust のプロジェクトのルートディレクトリーにあるとする。
こんなふうに書く。
require "ffi"
module FFIMath
extend FFI::Library
ffi_lib "target/release/libmy_ffi_math.dylib"
attach_function :hypot3, [:double, :double, :double], :double
end
p FFIMath.hypot3(1, 2, 3)
# => 3.7416573867739413
# 参考
p Math.sqrt(1 * 1 + 2 * 2 + 3 * 3)
# => 3.7416573867739413
実行結果もコメントで書いちゃっているが,Ruby で計算したのと結果が一致していることが分かる。
FFIMath
としたのは何でもいいから何かテキトーなモジュールを一つ用意しただけ。
このモジュールに対し,FFI::Library
を extend
する。これにより,ffi_lib
などいくつかの特異メソッドが生える。
ffi_lib
メソッドは,コンパイルで出来たライブラリーファイルのパスを指定している。
えっと,なんかよく分からないけど,相対パスだとうまくいかないことがあるようで,絶対パスを与えたほうがよさそう。そのためには File.expand_path を使って
ffi_lib File.expand_path("target/release/libmy_ffi_math.dylib", __dir__)
とする。こう書くと,このファイルの位置(__dir__
)からの相対パスを絶対パスにしてくれる。
attach_function
は,Rust で作った関数をモジュールの特異メソッドとして生やす。
第一引数が関数名。
第二引数は,関数の引数の型を指定している。3 引数なので,長さ 3 の配列になっている。:double
は FFI の倍精度浮動小数点数を表している。Rust では f64 にあたり,Ruby では Float にあたる。
第三引数は関数の返り値の型を指定している。
以上で,モジュールの特異メソッドが定義できた。
使い方は見てのとおり。
察しの良い方は,「ん? 引数には Float を与えなくちゃいけないのに Integer を与えてるぞ?」と疑問に思われるかもしれない。
これについては筆者もよく知らないけれど,きっと ffi gem が浮動小数点数に変換してくれているのだろう。
意外に簡単だった!
ベンチマークテスト
hypot3 を Rust で実装しようとしたのはそのスピードを期待してのことであった。
ならばベンチマークテストで実証せねばなるまい。さて,どの程度速くなるのだろう!
テストライブラリー
hypot3
のような軽い処理を計測する場合,ベンチマークテストのライブラリーは benchmark_driver 一択になると思う。
軽い処理を計測するには,同じ処理を何回も実行した時間を計測する必要があるが,times
メソッドなんかでループを回すと,ループのコストが相対的に無視できないため正確に測れない。benchmark_driver はそういうコストをかけずに多数回実行させるので,わりと実際の実行速度が測れる,ということらしい(どういう仕組みか知らないけど)。
テストコード
benchmark_driver の使い方は,
- ベンチマークテストプログラムを Ruby で書いて実行する
- ベンチマークの内容を YAML ファイルに書いて benchmark_driver コマンドに与える
の二通りあるが,今回は後者でやってみる。
後者は YAML フォーマットを覚える必要があるが,そう難しくはない。
以下のように書く。
prelude: |
require "ffi"
def Math.hypot3(x, y, z)
Math.sqrt(x * x + y * y + z * z)
end
module FFIMath
extend FFI::Library
ffi_lib "target/release/libmy_ffi_math.dylib"
attach_function :hypot3, [:double, :double, :double], :double
end
x, y, z = 3, 2, 7
benchmark:
- Math.hypot3(x, y, z)
- FFIMath.hypot3(x, y, z)
prelude
の中身は,計測に先立って何かしておくべきことを書く。
比較用に Math.hypot3
を定義しておいた。
テスト実行
ここまでできたら,ターミナルで
benchmark-driver benchmark.yaml
とする。(gem 名がアンダースコアなのにコマンド名がハイフンなのがややこしい)
あ,benchmark_driver gem のインストール
gem i benchmark_driver
をあらかじめやっておこうね。
結果は・・・
Comparison:
Math.hypot3(x, y, z): 10211285.3 i/s
FFIMath.hypot3(x, y, z): 5153872.8 i/s - 1.98x slower
えっ?
Math.hypot3
が毎秒 1000 万回実行できているのに対し,FFIMath.hypot3
は毎秒 500 万回。
ざ,惨敗ではないか・・。
速くなるどころか,話にならないくらい遅い4。
敗因は何だろう。Rust のコードはこれ以上改善しようがないように思える。コンパイルはちゃんとリリースビルドを指定した。
Rust の各種ベンチマークを見ると C と遜色ないようなので,Rust が遅い,というわけでもなさそうだ。
となると,やはり FFI のコストではないだろうか。
Ruby は引数に何でも渡せてしまうので,実行時に ffi gem が型のチェックや変換をしてくれているのではないかと思う。そういう余計な(?)処理が重荷になっているのかもしれない。
hypot3
程度の処理は,Rust で実装する意味が無いらしいと分かった。
もっと重い処理をやらせなくちゃいけないのだ。
気を取り直して「もっと重い処理」を何か考えることにしよう。
-
最近,2 乗の最適化が入ったので,たぶん Ruby 3.0 あたりで
x ** 2
はそれほど遅くないことになりそう。 ↩ -
Rust ではコード中にテストコードが書けるようになっており,テストの実行も
cargo test
とやるだけ,と非常に簡単になっている。 ↩ -
正確に言えば,生成物の拡張子は,どの OS 上でコンパイルしたかに依る,のではなく,どのターゲット用にコンパイルしたかに依る,ということなんじゃないかと思う。つまり,Windows 上で macOS 用(x86_64-apple-darwin)にコンパイルすれば
.dylib
になるんじゃないかな。Rust は簡単にクロスコンパイルができるものね。 ↩ -
ちなみに,このコードでは
x
,y
,z
に Integer オブジェクトを与えているが,Float オブジェクトを与えた場合,Math.hypot3
は 1 割以上速度が落ち,FFIMath.hypot3
のほうは変わらず,であった。 ↩