連記事目次

    • 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 のほうは変わらず,であった。 ↩

bannerAds