LoginSignup
4
2

More than 3 years have passed since last update.

Ruby/Rust 連携 (4) Rutie で数値計算

Last updated at Posted at 2020-09-05

はじめに

前回は 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] で終わっているが,これを以下のようにする。

Cargo.toml
[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 というファイルを以下のようにする。もともとテストコードの雛形が書かれているが,これは削除してしまって構わない。

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()

としているところだ。
さきのドキュメントによれば,Floatto_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] のところで

Cargo.toml
[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() じゃなくて,OkErr で場合分けしてやれば死なない関数にできる。
あるいは 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 スクリプトの鼻を明かしてやりますんで。


  1. macOS と,Windows の msvc および gnu で試したが,いずれもコンパイル時のリンクの段階でエラーが出る。詳しくは調べていないが,機会があれば別記事にまとめたい。 

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2