- 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 経由で
\sqrt{ x^2 + y^2 + z^2 }
を計算する Rust の関数を Ruby から呼んでみた。
意外と簡単に実装できることが分かったのは収穫だったが,肝心の速度はというと,Ruby で
Math.sqrt(x * x + y * y + z * z)
を計算するよりはるかに遅い,という残念なものだった。
原因はよく分からないが,FFI を経由するコストが大きかったのではないかと推測した。
では FFI 以外の手段で Rust と Ruby を繋いでみたらどうだろう。
この記事では Rutie というものを使う。
Qiita には Rutie の記事は無いようなので,これが最初の記事になると思う。
なお,
- macOS 10.13.6(High Sierra)
- Rust 1.46.0
- Ruby 2.7.1
を使用した。
Rutie とは
公式サイト:danielpclark/rutie: “The Tie Between Ruby and Rust.”
Rutie は「ルーティー」のように読むらしい。
FFI が複数の言語間を結ぶ汎用の仕組みであるのに対し,Rutie は Ruby と Rust の間だけを取り持つ。
大きな特長は,Rust で Ruby のクラス,モジュール,メソッドを書くことができる,ということだ。
Ruby の String,Array,Hash に対応する型も Rust 側で持っている。
また,Rust 側から Ruby のメソッドを呼ぶこともできるらしい。
題材
FFI のときと同じく
\sqrt{ x^2 + y^2 + z^2 }
を計算する関数を Rust で記述し,Ruby で呼んでみる。
FFI のときは,Rust の関数を Ruby のメソッドに割り当てる,という感じだったのに対し,Rutie の場合は Rust で直接 Ruby のメソッドを記述する,という感じになる。
実装:Rust 側
FFI のときと同様,Rust をあまり知らない人でも再現できるように書いていく。
ただし,Rust のインストールは済んでいるとする。
プロジェクト作成
まずターミナルで
cargo new my_rutie_math --lib
とやって,Rust のプロジェクトを一つ作る。
my_rutie_math
はプロジェクト名。これと同名のディレクトリーが出来,そこに初期ファイル一式が収まる。
--lib
は「ライブラリークレートを作るぞ」という指定。
Cargo.toml の編集
プロジェクトルートの Cargo.toml の末尾が [dependencies]
で終わっているが,これを以下のようにする。
[dependencies]
rutie = "0.7.0"
[lib]
crate-type = ["cdylib"]
(追記 2020-10-01)rutie の 2020-10-01 時点の最新版は 0.8.1 なので,いまから試す人は
rutie = "0.8.1"
としてください。
[dependencies]
[dependencies]
のところは,依存クレートの指定である。Ruby で言えば Gemfile で依存 gem を指定するようなもの。
ここでは,rutie というクレートを使いますよ,と言っている。
"0.7.0"
は rutie クレートのバージョン指定なのだが,「バージョン 0.7.0 にしろ」という意味ではなく「バージョン 0.7.0 以上,0.8.0 未満」という指定なのだ。
つまり,Ruby の Gemfile で言えば "~> 0.7.0"
という指定と同じ。
2020 年 9 月 4 日時点での rutie クレートの最新リリースはバージョン 0.8.0 なのだが,どういうわけか 0.8.0 ではうまくいかなかったので1,原因究明は後回しにして,一つ古い 0.7.0 で話を進める。
公式サイトの説明は 0.8.0 を前提に書かれているので注意されたい。
(追記 2020-10-01)その後,macOS で 0.8.0 を再び試したところ,とくに問題は無かった。0.8.1 も大丈夫だった。何か環境が変わったのかも。ビルドでエラーが出る方は教えてください。
[lib]
次の [lib]
のところの意味は私はよく分かっていない。
crate-type
は文字通りクレートのタイプを指定するもの。バイナリークレートとライブラリークレートのうち,ライブラリークレートを作るわけだが,実はライブラリークレートにも種類があるようだ。
以下の記事が役に立つ。
Rust の crate_type をまとめてみた - Qiita
cdylib
というのは,他言語(つまり Rust 以外の言語)用の動的ライブラリーを意味するらしい。
えっと,dy
は dynamic で,c
は C 言語を意味するのかな?
モジュールとメソッドの記述
では本体の記述を。
Rutie では Ruby のモジュールもクラスも作ることができる。
今回は関数(的メソッド)を一つ作りたいだけなので,クラスでなくモジュールにしよう。
モジュール名は MyMath
にする。
メソッド名は hypot3
にし,MyMath
の特異メソッドとして定義する。
方針は固まった。
src/lib.rs というファイルを以下のようにする。もともとテストコードの雛形が書かれているが,これは削除してしまって構わない。
#[macro_use]
extern crate rutie;
use rutie::{Object, Module, Float};
module!(MyMath);
methods!(
MyMath,
_rtself,
fn pub_hypot3(x: Float, y: Float, z: Float) -> Float {
let x = x.unwrap().to_f64();
let y = y.unwrap().to_f64();
let z = z.unwrap().to_f64();
Float::new((x * x + y * y + z * z).sqrt())
}
);
#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_MyMath() {
Module::new("MyMath").define(|module| {
module.def_self("hypot3", pub_hypot3)
});
}
FFI 版と比べると記述量がやや多い。
マクロ
まず,module!
と methods!
に注目。
これらは rutie クレートで定義されているマクロで,Ruby のモジュールとメソッドを定義するものらしい。
これらのマクロを使用するため,冒頭に
#[macro_use]
extern crate rutie;
という記述が必要になる(知らんけど)。
Object, Module, Float
Rutie では,Ruby の Object,Module,Class,Array,Float,Hash,Symbol といったクラスに対応する Rust の型が同じ名前で定義されているようだ。
ただし,Ruby の String については同名ではなく RString という名前になっている。Rust の String とかぶらないように R
を付けたのだろう。
今回はこれらのうち,Object, Module, Float の三つが必要になるので,
use rutie::{Object, Module, Float};
と書いておく。
モジュールの定義
MyMath
という Ruby のモジュールを作るには
module!(MyMath);
と書く。
たぶん実際にモジュールが出来るのはこの箇所ではなく,あとのほうに出てくる Module::new("MyMath")
を実行したときなんだろう。
メソッドの定義
メソッドの定義は methods!
マクロに三つの引数を与えている。
第一引数はモジュール名 MyMath
。
第二引数の _rtself
はさっぱり分からない。
第三引数に関数の定義を与えている。
抜き出してみよう:
fn pub_hypot3(x: Float, y: Float, z: Float) -> Float {
let x = x.unwrap().to_f64();
let y = y.unwrap().to_f64();
let z = z.unwrap().to_f64();
Float::new((x * x + y * y + z * z).sqrt())
}
まず関数名なのだが,pub_
を頭に付けているのは,Rutie のサイトに載っているコード例で,「関数名が他とかぶらないよう pub_
を付けた」としているのに倣ったもので,かぶらないことがはっきりしていれば付けなくてよい。
なお,pub_hypot3
が Ruby 側では hypot3
というメソッド名になるようにするので,安心されたい。
さて,引数,返り値はともに f64
でなく Float
型になっている。
Float
はここで定義されているようだ:
https://github.com/danielpclark/rutie/blob/v0.7.0/src/class/float.rs
ここに書かれたコメントがドキュメントとして下記で見られる:
https://docs.rs/rutie/0.7.0/rutie/struct.Float.html
ここで大きな疑問が涌いた。引数から f64
に変換するところで
x.unwrap().to_f64()
としているところだ。
さきのドキュメントによれば,Float
は to_f64()
で f64
に変換できるはず。
なぜに unwrap()
をかましているのか?
どうも,この x
の型は
std::result::Result<rutie::Float, rutie::AnyException>
であるらしい。おそらく,Ruby から値をもらうときに,変なものを渡される可能性があるので Result
になっているんだろうな。だから unwrap()
で Float
型の値を取り出すのだろう。
んが,しかし! 関数の型は
fn pub_hypot3(x: Float, y: Float, z: Float) -> Float
となっていたではないか。Float
だよ,x
は。
ぁん〜?
調べても私の能力では分からなかった。
ただ,この箇所は methods!
マクロの引数である,というところがポイントかもしれないと思った。
そう,この関数定義らしきものはマクロに渡される何かなのだ。Rust の関数そのものではない。
この問題は脇に置いて先に進もう。
関数本体では
let x = x.unwrap().to_f64();
としている。引数に x
があるのに同名の x
を定義しているのは,いわゆるシャドーイングというやつ。元の x
はもうここ以降では要らないので,同じ名前の変数を使うね,ってこと。
最後の
Float::new((x * x + y * y + z * z).sqrt())
は,計算された f64
の値を元に,Ruby 側に返す Float
型の値を生成しているところ。
初期化関数の定義
「初期化関数」という呼び方は私が勝手に考えたもので,適切でないかもしれない。
ともかく,Ruby 側から呼び出す関数を一つ定義する。これを実行することによって,Rust で定義した Ruby のモジュールやメソッドが Ruby 側で実際に使えるようになるのだろうと思う。
以下に抜粋する。
#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_mymath() {
Module::new("MyMath").define(|module| {
module.def_self("hypot3", pub_hypot3)
});
}
関数名の命名規則が分からないが,コード例に従って,Init_XXXX
の形式にした。
冒頭に #[allow(non_snake_case)]
とあるのは,「スネークケースにしなかったのは意図的なんだから文句言うなよ」とコンパイラーに釘を刺しているのだろう。
#[no_mangle]
は,ライブラリーが外部に見せる関数を定義するときに付けるおなじみの呪文で,これをつけないと関数名がその名で参照できなくなるということらしい。
関数の中身は,まず Module::new("MyMath")
でモジュール MyMath
を作成し,それに対して def_self
でメソッドを生やしているようだ。
define
の引数は
|module| {
module.def_self("hypot3", pub_hypot3)
}
という形になっている。これはクロージャーと呼ばれるもの。
Ruby のブロックとよく似た構文になっているのが面白い。Ruby のブロックパラメーターに当たる部分が { }
の外に出ている点が違っているけれども。
Ruby のブロックは値ではない(オブジェクトではない)が,Rust のクロージャーは値であり,関数の引数に渡すことができる。
module.def_self("hypot3", pub_hypot3)
は,先に定義した pub_hypot3
を,hypot3
という名前のメソッドとしてモジュール MyMath
に生やすことを意味するようだ。
これで Rust 側の実装はオシマイ。
いくつか理解できない点があったし,ちょっとややこしい感じもする。
しかし,この程度の複雑さで,Ruby のクラスやモジュールやメソッドが定義できるなら,いいんではないか?
コンパイル
プロジェクトのルートディレクトリーで
cargo build --release
とやる。
すると,成果物が target/release/libmy_rutie_math.dylib
というパスに出来る。
ただし,拡張子はターゲットによって異なるはず。Windows だと .dll
になるのかな。
(追記)
コンパイルするとき,
warning: `extern` fn uses type `MyMath`, which is not FFI-safe
--> src/lib.rs:9:5
|
9 | MyMath,
| ^^^^^^ not FFI-safe
という警告が出る。
(Ruby や Rust のバージョンは記事の冒頭に書いておいた)
MyMath
という名前が「FFI-safe でない」と言っているようだ。
どういうことか全く分からないが,エラーでなく警告なので,さしあたり無視することにする。
(追記 2020-10-01)not FFI-safe
という警告は,Rust 1.46 で出るようになったもの。Rutie 0.8.1 で解消された。
https://github.com/danielpclark/rutie/issues/128
実装:Ruby 側
Ruby 側では,rutie
という gem を使う。Rust で使うクレートと同じ名前。分かりやすい。
以下のサンプルスクリプトは,Rust のプロジェクトのルートディレクトリーに存在すると仮定して書いている。
gem "rutie", "~> 0.0.4"
require "rutie"
Rutie.new(:my_rutie_math, lib_path: "target/release").init "Init_mymath", __dir__
p MyMath.hypot3(1.0, 2.0, 3.0)
# => 3.7416573867739413
# 参考
p Math.sqrt(1 * 1 + 2 * 2 + 3 * 3)
# => 3.7416573867739413
1 ファイルで示そうとして,上のサンプルでは いきなり gem "rutie", "~> 0.0.4"
とやっているが,ふつうは Gemfile に書くだろう。
さて,rutie gem の使い方だが,まず最初に Rutie.new
で Ruby オブジェクトを作るようだ。
第一引数に :my_rutie_math
と書いたが,これは Rust で作ったライブラリーの名前。
この記事では,最初に cargo new
したときに与えたプロジェクト名がそのままライブラリー名になっている。
しかし,Cargo.toml の [lib]
のところで
[lib]
name = "hoge"
のように name
を与えてやれば,それがライブラリー名になるはず。
そして,それはコンパイルして出来た成果物のファイル名に反映されるはずだ。
オプションの引数の lib_path
についてはあとで触れる。
ともかく,そうやって出来た Rutie オブジェクトの init
を呼び出す。
第一引数の "Init_mymath"
は,私が「初期化関数」と仮に呼んだアレの名前。
第二引数はすぐあとで触れる。
ともかく,こうやって init
すると,Rutie さんが使うべきライブラリーファイル libmy_rutie_math.dylib
を見つけて Init_mymath
関数を呼び出してくれるわけだ。
繰り返しになるが,このファイルの拡張子はターゲットによって異なる。
Rutie さんはそのへんもうまく考えて見つけてくれるのだ。
で,その見つける場所なんだけども,ちょっとややこしい。
まず,init
の第二引数を基準にして,そこから,lib_path
に与えた相対パスだけ移動したところにある,と見るのである。
この記事の場合,Ruby スクリプトは Rust のプロジェクトのルートに置いたから,__dir__
はそこ。
で,そこから見た target/release
にファイルを見出すわけ。
lib_path
や他のオプションを与えなかった場合,"../target/release"
になる。
今の場合,これだと都合が悪いので lib_path
を指定した。
使う
使い方は簡単。コード例のとおり。
MyMath
モジュールに特異メソッド hypot3
が生えているので,それをふつうに呼ぶだけ。
確認のため Math.sqrt(1 * 1 + 2 * 2 + 3 * 3)
も表示しているが,同じ数値が得られた。
ただし,一つ注意しなければならないことがある。
hypot3
の引数は三つとも Float
である,と(Rust 側で)決めた。
もし,MyMath.hypot3
に Integer オブジェクトを与えたらどうなるのか?
やってみた。死んだ。いわゆる panic というやつだ。
Float 以外の物を食わせると x.unwrap()
の箇所で死ぬのだ。
もちろん,Rust 側で,いきなり unwrap()
じゃなくて,Ok
と Err
で場合分けしてやれば死なない関数にできる。
あるいは Ruby 側で,Float に型キャストして呼ぶようにすれば問題ない。
ベンチマークテスト
前回(Ruby/Rust 連携 (3) FFI で数値計算)は,FFI を直に使うやり方で hypot3
をやってみて,「Rust を呼ぶより Ruby で書いたほうがずっと速かった」という残念な結果を得た。
Rutie 版はどうだろうか。あまり期待しないでやってみよう。
テストコード
今回も benchmark_driver という gem を使って計測する。
あらかじめ
gem i benchmark_driver
とやってインストールしておく。
(よく混乱するけど,gem 名はハイフンじゃなくてアンダースコア)
今回は前回と違って,テストコードを Ruby で書いてやってみる。
ちょっと気をつけなければならないのが,さきほどのサンプルコードではココを表すのに __dir__
を使ったのだが,benchmark_driver でそう書くと,ベンチマークプログラムの居場所ではなく,benchmark_driver が生成する一時ファイルの居場所になってしまって,Rust のライブラリーが見つけられなくなる,ということ。
以下のコードではそこを一工夫した。
require "benchmark_driver"
Benchmark.driver do |r|
r.prelude <<~EOT
gem "rutie", "~> 0.0.4"
require "rutie"
Rutie.new(:my_rutie_math, lib_path: "target/release").init "Init_mymath", "#{__dir__}"
EOT
r.report "MyMath.hypot3(1.0, 2.0, 3.0)"
r.report "Math.sqrt(1.0 * 1.0 + 2.0 * 2.0 + 3.0 * 3.0)"
end
prelude
は,計測に先立ってやっておくことを書く。
report
は計測したい処理を書く。
テスト実行
さきほどのスクリプトを実行すると:
Math.sqrt(1.0 * 1.0 + 2.0 * 2.0 + 3.0 * 3.0): 11796989.3 i/s
MyMath.hypot3(1.0, 2.0, 3.0): 5684591.1 i/s - 2.08x slower
ざ,惨敗ですやん。
Rutie 版の実行速度は前回の FFI 版とほぼ同じ。Ruby で書いたのの倍の時間がかかっている。
あの,今日はもう寝ていいすか?
次はもっと重い処理を Rust にやらせて Ruby スクリプトの鼻を明かしてやりますんで。
-
macOS と,Windows の msvc および gnu で試したが,いずれもコンパイル時のリンクの段階でエラーが出る。詳しくは調べていないが,機会があれば別記事にまとめたい。 ↩