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


連記事目次

* [Ruby/Rust 連携 (1)　目的](https://qiita.com/scivola/items/1f51a294c86a70aa78aa)
* [Ruby/Rust 連携 (2)　手段](https://qiita.com/scivola/items/d6de19cd985a5cd27f7e)
* Ruby/Rust 連携 (3)　FFI で数値計算
* [Ruby/Rust 連携 (4)　Rutie で数値計算①](https://qiita.com/scivola/items/ce00cd5f37a9d01754ea)
* [Ruby/Rust 連携 (5)　Rutie で数値計算②　ベジエ](https://qiita.com/scivola/items/2ab41a03beb00f6ce3d2)
* [Ruby/Rust 連携 (6)　形態素の抽出](https://qiita.com/scivola/items/b131aab4e637c4d782ee)
* [Ruby/Rust 連携 (7)　インストール時ビルドの Rust 拡張 gem を作る](https://qiita.com/scivola/items/8f101dc301c8df385c5c)

# はじめに

FFI（Foreign Function Interface）を使って，簡単な数値計算を行う Rust の関数を Ruby から呼ぶ，ということをやってみよう。
既にこのようなテーマの記事は複数存在するので N 番煎じなのだが。

なお，

* macOS 10.13.6（High Sierra）
* Rust 1.46.0
* Ruby 2.7.1

を使用した。

# 題材

どうせならフィボナッチ数より実用性のあるものが作りたい。
よし，アレにしよう。
[Math.#hypot](https://docs.ruby-lang.org/ja/2.7.0/method/Math/m/hypot.html) の 3 変数版だ。3 次元版と言ってもいい。

`Math.hypot` という奇妙な名前のモジュール関数は，要するに

```math
\sqrt{ x^2 + y^2 }
```

を計算してくれるもの。
直角三角形の直交する二辺の長さ $x$, $y$ から斜辺（hypotenuse）の長さを計算するのでこの名がある。

```rb
p Math.hypot(3, 4) # => 5.0
```

これの 3 変数版は，要するに

```math
\sqrt{ x^2 + y^2 + z^2 }
```

を計算する関数というわけだ。


## 動機

どうして `Math.hypot` の 3 変数版が欲しいか，という話をする。記事の主題と関係ないので，次節に飛んでもらって構わない。

この関数は，平面上の 2 座標が与えられたとき，その 2 点間の距離を得るのに使える。
つまり，

```rb
p1 = [1, 3]
p2 = [2, -4]

distance = Math.hypot(p1[0] - p2[0], p1[1] - p2[1])
```

というように。
まあ，[Vector](https://docs.ruby-lang.org/ja/2.7.0/class/Vector.html) を使えば

```rb
require "matrix"

p1 = Vector[1, 3]
p2 = Vector[2, -4]

distance = (p1 - p2).norm
``` 

でいけるんだけどもね。

話が逸れた。

では 3 次元空間中の 2 点間の距離は，となると，当然 `hypot` の 3 次元版が欲しくなるわけだ。
いやもちろん，`Vector` を使えば上記のように何次元だろうが簡単に書けるわけだが，速度その他の理由で `Vector` を使いたくない場合もあるだろう，と。

3 変数版は

```rb
def Math.hypot3(x, y, z)
  Math.sqrt(x * x + y * y + z * z)
end
```

と，簡単に定義できる。
ちなみに `x ** 2` とせずに `x * x` とした理由は，前者がたいへん遅いからである[^square]。

[^square]: 最近，2 乗の最適化が入ったので，たぶん Ruby 3.0 あたりで `x ** 2` はそれほど遅くないことになりそう。

しかし，上記のメソッドは，乗算 3 回，加算 2 回，`sqrt` 1 回あり，これらは全部メソッド呼び出しだから，計 6 回もメソッドを呼んでいる。
こういう式は，もしかすると Rust や C で書き直すと速くなるのかもしれない。

# 実装：Rust 側

Rust をよく知らない人でも再現できるように書くね。
ただし，Rust のインストールは済んでいるとする。

## プロジェクト作成

まずターミナルで

```shell
cargo new my_ffi_math --lib
```

とやって，Rust のプロジェクトを一つ作る。
my_ffi_math はプロジェクト名。
オプションの `--lib` は，「ライブラリークレートを作る」という指定。
クレートというのはコンパイルの単位なのだが，

* バイナリークレート：実行ファイルを作る
* ライブラリークレート：ライブラリーを作る

という二種類がある。

## Cargo.toml の編集

プロジェクトのルートに Cargo.toml というファイルがある。ここに，プロジェクト全体に関わるいろいろな設定などが書かれる。
このファイルの末尾に

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

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

## 関数の作成

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

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

そして，

```rust:src/lib.rs
#[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 に対応する。
`->` は関数の返り値の型を表している。
関数の中身は，見ればまあなんとなく分かる。
関数定義の前に付いている

```rust
#[no_mangle]
```

が気になるね。
筆者にもよく分かってないけど，これを書いておかないと，せっかく `hypot3` という名前で関数を定義したのに，コンパイル後にその名前では参照できなくなる，ということらしい。

Rust 側の実装は以上でオシマイ。

## コンパイル

プロジェクトのルートディレクトリーで

```shell
cargo build --release
```

とやると，コンパイルされる。
<ruby>`build`<rt>ビルド</ruby> というのはコンパイルをカッコよく言ったもの（？　いや，たぶん違う）
ビルドは，デバッグ用とリリース（本番）用の二通り可能で，`--release` は文字通りリリース用にビルドしろ，ということ。
デバッグ用だと実行速度が遅い。

コンパイルして出来たものは target/release/libmy_ffi_math.dylib というパスにあるはずだ。
あ，いや，ファイル名の拡張子はイロイロなんだよな。macOS 上でやると `.dylib` になったけど，Windows とかでは違うはず[^libext]。

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

Ruby 側で必要になるのはこのファイルだけ。

ファイル名のベース名（拡張子を除いた部分）は，今の場合，プロジェクト名の頭に `lib` を付けたものになっている。
もし，Cargo.toml の `[lib]` のところに `name` を追記して

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

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

# 実装：Ruby 側

Ruby 側では [ffi](https://github.com/ffi/ffi) という gem を使う（[fiddle](https://github.com/ruby/fiddle) を使う方法もある）。

以下のコードでは簡単に

```rb
require "ffi"
```

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

```rb:Gemfile
gem "ffi", "~> 1.13"
```

とでも書いておいて，スクリプトで

```rb
require "bundler"
Bundler.require
```

などとすればよい。

また，Ruby のコードはとりあえず Rust のプロジェクトのルートディレクトリーにあるとする。

こんなふうに書く。

```rb
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](https://docs.ruby-lang.org/ja/2.7.0/method/File/s/expand_path.html) を使って

```rb
  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](https://github.com/benchmark-driver/benchmark-driver) 一択になると思う。

軽い処理を計測するには，同じ処理を何回も実行した時間を計測する必要があるが，`times` メソッドなんかでループを回すと，ループのコストが相対的に無視できないため正確に測れない。benchmark_driver はそういうコストをかけずに多数回実行させるので，わりと実際の実行速度が測れる，ということらしい（どういう仕組みか知らないけど）。

## テストコード

benchmark_driver の使い方は，

* ベンチマークテストプログラムを Ruby で書いて実行する
* ベンチマークの内容を YAML ファイルに書いて benchmark_driver コマンドに与える

の二通りあるが，今回は後者でやってみる。
後者は YAML フォーマットを覚える必要があるが，そう難しくはない。

以下のように書く。

```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` を定義しておいた。



## テスト実行

ここまでできたら，ターミナルで

```shell
benchmark-driver benchmark.yaml
```

とする。（gem 名がアンダースコアなのにコマンド名がハイフンなのがややこしい）
あ，benchmark_driver gem のインストール

```shell
gem i benchmark_driver
```

をあらかじめやっておこうね。


結果は・・・

```shell
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 万回。
ざ，惨敗ではないか・・。
速くなるどころか，話にならないくらい遅い[^osoi]。

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

敗因は何だろう。Rust のコードはこれ以上改善しようがないように思える。コンパイルはちゃんとリリースビルドを指定した。
Rust の各種ベンチマークを見ると C と遜色ないようなので，Rust が遅い，というわけでもなさそうだ。

となると，やはり FFI のコストではないだろうか。
Ruby は引数に何でも渡せてしまうので，実行時に ffi gem が型のチェックや変換をしてくれているのではないかと思う。そういう余計な（？）処理が重荷になっているのかもしれない。

`hypot3` 程度の処理は，Rust で実装する意味が無いらしいと分かった。
もっと重い処理をやらせなくちゃいけないのだ。

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