8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Ruby/Rust 連携 (3) FFI で数値計算

Last updated at Posted at 2020-09-04

連記事目次

はじめに

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 というファイルがある。ここに,プロジェクト全体に関わるいろいろな設定などが書かれる。
このファイルの末尾に

Cargo.toml
[lib]
crate-type = ["cdylib"]

と追記する。
これの意味は筆者にはよく分かってない。

関数の作成

src/lib.rs というファイルがあるはずだ。
ここには,テストコードの雛形が書かれているが2,これは削除してしまって構わない。

そして,

src/lib.rs
#[no_mangle]
pub extern fn hypot3(x: f64, y: f64, z: f64) -> f64 {
    (x * x + y * y + z * z).sqrt()
}

と書く。

fn が関数の定義を表すキーワード。pubextern はそれに対するオマケで,ええと,ちゃんと説明できないっす。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 を追記して

Cargo.toml
[lib]
name = "hoge"
crate-type = ["cdylib"]

のようにすれば,ファイル名は libhoge.dylib のようになるはず。

実装:Ruby 側

Ruby 側では ffi という gem を使う(fiddle を使う方法もある)。

以下のコードでは簡単に

require "ffi"

とやるが,もちろん Gemfile で管理するなら

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::Libraryextend する。これにより,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 フォーマットを覚える必要があるが,そう難しくはない。

以下のように書く。

benchmark.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 で実装する意味が無いらしいと分かった。
もっと重い処理をやらせなくちゃいけないのだ。

気を取り直して「もっと重い処理」を何か考えることにしよう。

  1. 最近,2 乗の最適化が入ったので,たぶん Ruby 3.0 あたりで x ** 2 はそれほど遅くないことになりそう。

  2. Rust ではコード中にテストコードが書けるようになっており,テストの実行も cargo test とやるだけ,と非常に簡単になっている。

  3. 正確に言えば,生成物の拡張子は,どの OS 上でコンパイルしたかに依る,のではなく,どのターゲット用にコンパイルしたかに依る,ということなんじゃないかと思う。つまり,Windows 上で macOS 用(x86_64-apple-darwin)にコンパイルすれば .dylib になるんじゃないかな。Rust は簡単にクロスコンパイルができるものね。

  4. ちなみに,このコードでは x, y, z に Integer オブジェクトを与えているが,Float オブジェクトを与えた場合,Math.hypot3 は 1 割以上速度が落ち,FFIMath.hypot3 のほうは変わらず,であった。

8
3
2

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
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?