連記事目次
- 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 を作る
はじめに
今回は,ソースに Rust コードを含んだ gem の例。
以下の記事が参考になる。
Rust でつくるかんたん Ruby Gem - Qiita
上記の記事では,開発時に Rust のコンパイルを行い,生成物を gem のパッケージに含める,という形を取っていた。
それに対し,gem のインストール時に Rust のコンパイル(ビルド)を行う方法 を取ってみよう。
そのためには,当然インストール先の環境に Rust がインストールされていなければならない。
今回試作した gem を GitHub にさらす。gem 名は rust_gem_sample01 とした。
https://github.com/scivola/rust_gem_sample01
やり方を示すための試作品なので RubyGems.org には入れていない。
macOS,Linux,Windows のいずれでもインストールできるように書いたつもり。
2022-12-28 追記
2022 年暮れ,Bundler 2.4 がリリースされた。
このバージョンでは,Rust 拡張 gem への配慮が盛り込まれ,新しく gem を作るときに --ext
オプションに rust
を指定して
bundle gem hoge --ext=rust
みたいにすれば Rust コードも含めたひな形を生成してくれるようだ。
今後はこれに則って作成するのがベストだろう。
題材
この gem は実用目的ではないので,なるべく単純な例を。
Ruby の組込みメソッドや標準添付ライブラリーには,一様分布以外の分布を持つ乱数生成機能が無いので,それをやろう。
とりあえず正規分布の乱数を返すやつ。
こんな感じで使えるもの:
require "rust_gem_sample01"
standard_deviation = 2.0
p RustGemSample01.rand_norm(standard_deviation)
# => -3.1674786173729506
RustGemSample01 モジュールに特異メソッド rand_norm
を定義。引数に標準偏差を与えると平均値が 0 の乱数を返す。
正規分布は平均値と標準偏差の二つのパラメーターを持つが,平均値を変えたければ足し算すればいいだけなので,引数は標準偏差だけとした。引数が多いほど FFI のコストが大きくなるので,これでいいと思う。
インストールしてみよう
RubyGems.org には載せていないので,
gem install rust_gem_sample01
ではインストールできない。
以下の手順で,リポジトリーをクローンし,gem のビルド&インストールをしてみてほしい。
いろんな環境でちゃんとインストールできるかどうか知りたいので,協力していただけるとありがたい。
git clone https://github.com/scivola/rust_gem_sample01.git
cd rust_gem_sample01
rake install
この最後の rake install
というのは,gem のプロジェクトファイルから gem のパッケージを作り1,システムに(つまりグローバルに)その gem をインストールする,というもの。
上を実行するためには,Git がインストールされていることと,Rust(バージョン 1.40.0 以上)がインストールされている必要がある2。
インストールに失敗するとすれば,Rust コードのビルドのところか,ビルドの生成物を移動するところだと思う。
インストール時に Rust のコンパイルが行われるが,そこでは依存クレートのネットからのダウンロードなどなども行われるので,場合によって数十秒かかることもある。応答が無くても気長に待ってほしい。
sudo gem install の場合
Linux とかだと,システムワイドに Ruby をインストールしていて,gem をインストールするとき
sudo gem install hoge
のように sudo
を付けなければならない場合も多いと思う。
この場合,本 gem をインストールするには,cargo も sudo で動くようになっていなければならない。
rustup を使ってインストールした Rust はそのユーザーのホームディレクトリーに入る(よく知らんけど)。
なので,sudo
を付けてインストールすれば,/root/.cargo
に入り,sudo cargo
できるようになる(知らんけど)。
使ってみよう
require "rust_gem_sample01"
n = 10000
bin = Hash.new(0)
n.times do
bin[RustGemSample01.rand_norm(2.5).round] += 1
end
bin.keys.minmax.then{_1.._2}.each do |b|
puts "%4d %s" % [b, "*" * (bin[b].fdiv(n) * 60 * 4).round]
end
これは,平均が 0,標準偏差が 2.5 の正規分布の乱数を 10000 回発生させ,その値を整数に丸めたもののヒストグラム(頻度分布図)を描くもの。
だいたいこんなものが出力される。
-11
-10
-9
-8
-7 *
-6 **
-5 *****
-4 ***********
-3 ******************
-2 ******************************
-1 **********************************
0 ************************************
1 **********************************
2 *****************************
3 ******************
4 ***********
5 ******
6 **
7 *
8
9
10
うん,なんかそれっぽいね。
もし,プログラムが動かなくてエラーが出るようなら教えて欲しい。
ファイル構成
.
├── bin
│ ├── console
│ └── setup
├── Cargo.toml
├── CHANGELOG.md
├── ext
│ └── Rakefile
├── Gemfile
├── lib
│ ├── rust_gem_sample01
│ │ └── version.rb
│ └── rust_gem_sample01.rb
├── LICENSE
├── pkg
├── Rakefile
├── README.md
├── rust_gem_sample01.gemspec
├── src
│ └── lib.rs
└── test
├── rust_gem_sample01_test.rb
└── test_helper.rb
ちなみに,この図は Rust 製のコマンドラインツール exa で作った。
gem 用ファイルと Rust パッケージ用ファイルの混在
見てのとおり,gem のためのファイルと Rust パッケージのファイルが同階層に混在している。
かいつまんで説明すると,bin
, lib
, pkg
, Rakefile
, rust_gem_sample01.gemspec
, test
あたりが gem のファイル。
Cargo.toml
, src
が Rust パッケージのファイル。Rust のビルドを行うと target
ディレクトリーなんかも出来る。
ファイル名,ディレクトリー名がかぶらないので,これで問題ないわけだが,これがよい流儀かどうかは判らない。Rutie のサンプルなんかはこういう構成になっていた(たしか)。
少なくとも初見で直ちに区別ができるかというと,心もとない。
Rust 関係を一つのディレクトリーに押し込めたほうがいいかもしれない3。
Rust コード
Rust コードはライブラリークレート rand_distr_for_ruby
だけを持つパッケージとなっている。
依存クレートは
[dependencies]
rand = "0.7.3"
rand_distr = "0.3.0"
だけ。
rand はお馴染み。rand_distr のほうは,もともと rand
に入っていた,正規分布,コーシー分布,二項分布,ポアソン分布などなどの〈一様でない〉分布の乱数を発生させる関数を独立させたものらしい(知らんけど)。
コード本体はこれだけ:
use rand_distr::{Normal, Distribution};
#[no_mangle]
pub extern fn rand_norm(variance: f64) -> f64 {
let normal = Normal::new(0.0, variance).unwrap();
normal.sample(&mut rand::thread_rng())
}
呼ばれるたびに rand_distr::Normal
を生成しているのは無駄な気がする。
まあ実用目的じゃないので。
同じ分布で多数の乱数を発生させるなら,ここは一度で済ませたい。生成した rand_distr::Normal
をずっと保持するのは,Rutie を使えば簡単にできるが((5) 参照),FFI だけでどうやるのかよく分からない。
さて,開発中は,この状態で
cargo check
だの
cargo build --release
だのできる。
プロジェクトのルートディレクトリーに Rust のファイルを置くことで,階層を行ったり来たりしなくてすむのは,このディレクトリー構成の利点。
Ruby コード
Ruby 側の中核となるコードがこれ。
require "ffi"
require "rust_gem_sample01/version"
module RustGemSample01
extend FFI::Library
lib_name = "rand_distr_for_ruby"
file_name =
case RbConfig::CONFIG["host_os"].downcase
when /darwin/ then "lib#{lib_name}.dylib"
when /mingw|mswin/ then "#{lib_name}.dll"
when /cygwin/ then "cyg#{lib_name}.dll"
else "lib#{lib_name}.so"
end
ffi_lib File.expand_path(file_name, __dir__)
attach_function :rand_norm, [:double], :double
end
ffi という gem を使って Rust の関数を Ruby のメソッドに割り当てている。
FFI(Foreign Function Interface)という,異なる言語間でやり取りするための仕組みを簡単に扱えるようにしたもの。
このモジュール定義のコードの大部分が,ffi_lib
メソッドに渡す引数の決定に費やされている。
このメソッドには読み込むべきライブラリーファイルのパスを渡す。
Rust でコンパイルして出来たライブラリーファイルは,OS によってファイル名が少し異なるのでこんなややこしいことになっているのだ。
この処理が本当に合っているのかよく分からないが,Rutie のコード rutie-gem/lib/rutie.rb を参考にした。
attach_function :rand_norm, [:double], :double
の部分は,読み込んだライブラリーに基づいて,モジュールに Ruby のメソッドを生やす。
第一引数はライブラリーの関数の名前。これが Ruby のメソッドの名前にもなる。
第二引数は関数の引数の型。引数は複数ありうるので配列で与える。:double
というのは,FFI の仕様で決められた型の名前で,倍精度浮動小数点数を表す。よくは知らないけど C 言語の double
のことだろう。Ruby の Float がこれに対応する。
第三引数は関数の返り値の型。
Rust で作ったライブラリーの関数を,FFI を通して Ruby 側で利用するのは,もうホントにこれだけで済む。簡単。
gem に仕立てる
gemspec
gemspec について,とくに説明を要するところはここ。
# 抜粋
Gem::Specification.new do |spec|
# 中略
spec.add_dependency "ffi", "~> 1.13.1"
spec.extensions = %w[ext/Rakefile]
end
ffi gem を使っているのでそれをランタイム依存性に追加する(add_dependency
)のは当然として,重要なのはその次の
spec.extensions = %w[ext/Rakefile]
のところ。これはなんじゃらほい?
これは gem のインストール時にやるべきことを記述した Rake ファイルのパスを指定したもの。
C 拡張の場合,その役目はふつう extconf.rb
というファイルが担う。sqlite3 gem であれば sqlite3-ruby/ext/sqlite3/extconf.rb がそれにあたる。
全然よく分かってないのだが,C 拡張はこのファイルにしたがって C のコンパイルやらなんやらをやっているらしい。
しかし,どうやら extconf.rb
は C を前提としているらしく,Rust の場合にどう書くのかよく分からない。というか,そもそも Rust では使えないような気がした。
他に手段はないのかと探してみると,どうも Rake タスクを使う方法もあると分かった。次節で具体的に述べる。
Rakefile
gemspec で spec.extensions =
として指定した Rake ファイルに,default
タスクとして書いたことがインストール時に実行されるらしい。
# gem のインストール時の処理
# default タスクとして記述する
task :default do
# Rust がインストールされているか
# cargo コマンドが動作するかで判断
begin
cargo_v = `cargo -V`
raise Errno::ENOENT if cargo_v.empty?
rescue Errno::ENOENT
raise "Cargo not found. Install it."
end
# Rust のバージョン(Cargo のバージョンと一致)が一定以上か
cargo_version = cargo_v.match(/\Acargo (\d+)\.(\d+)\.(\d+) /)[1..3].map(&:to_i)
if (cargo_version <=> [1, 40, 0]).negative?
raise "Too old Cargo (ver. #{cargo_v}). Update it."
end
# Rust のビルド
system "cargo build --release", chdir: __dir__ + "/.."
# 生成物のファイル名
# OS によって異なる
lib_name = "rand_distr_for_ruby"
file_name =
case RbConfig::CONFIG['host_os'].downcase
when /darwin/ then "lib#{lib_name}.dylib"
when /mingw|mswin/ then "#{lib_name}.dll"
when /cygwin/ then "cyg#{lib_name}.dll"
else "lib#{lib_name}.so"
end
# 生成物を lib/ 直下に移動
FileUtils.mv __dir__ + "/../target/release/#{file_name}", __dir__ + "/../lib/"
FileUtils.rmtree __dir__ + "/../target/"
end
コード中にコメントを書いたが,やっていることは要するに
- Rust 入ってる?
- Rust のバージョン OK?
- ビルド
- ライブラリーファイルの移動
と,ただこれだけ。
Rust コードのビルドの生成物のファイル名を得るところが lib/rust_gem_sample01.rb
とかぶってて DRY じゃないのが気になるけど。
まあ,target/release/
直下のファイルを全部移動してもいいのかもしれない。
ところで,Rust でビルドを行うと,必要なライブラリーをダウンロードしてコンパイルして,という過程でいろいろなファイルが出来る。今回の gem の場合,18 MB くらいのファイルがビルド時に生成される。欲しいのはファイル一つ(今回は 300 KB 程度)で,ほかは全部不要。
そこで,タスクの最後の行
FileUtils.rmtree __dir__ + "/../target/"
によりお掃除している。
2020-10-19 追記
ある環境で正常にインストールできないことが分かったので,rust_gem_sample01 0.2.1 で Rakefile を修正した。上に掲げたコードは修正版に差し替えた。
修正したのは,cargo
が入っているかどうかを判定する
begin
cargo_v = `cargo -V`
raise Errno::ENOENT if cargo_v.empty?
rescue Errno::ENOENT
raise "Cargo not found. Install it."
end
の箇所。
これ一見して「は? 何やってんの?」と言われそうなコード。
当初は raise Errno::ENOENT if cargo_v.empty?
の行が無かった。
cargo
が無い場合,
`cargo -V`
が例外を出してくれるので,それを拾っていたわけなんだが,どうも sudo
で実行すると,ここで例外が出ず,単に空文字列を返すようだ。
だから,例外が出なくても cargo_v
が空文字列だったら例外を出してやっている。
おわりに
C 拡張の場合,gem にコンパイル済みのファイルを含めて配布すべきか否かについて議論がある。
参考:
一長一短があるが,Rust の場合はどうだろう。
おそらくふつうは事前コンパイルを選ぶのだろう。だって,Ruby ユーザーの多くは Rust のコンパイル環境を持っていないだろうから。
なので,この記事ではあえて多くの人がやらなさそうな方法を試してみた。
-
gem のパッケージを作るのは
build
という別の Rake タスクなのだが,install
タスクがbuild
タスクに依存しているので,rake install
だけで両方やってくれる。 ↩ -
Rust のバージョンを 1.40.0 以上としたのは全く何の根拠もない。もっと低いバージョンでも何ら問題ないはずだが,どのバージョンで何がどうなったか把握してないので,最新版(現時点で 1.46.0)よりちょっと古いものにしてみた。バージョン下限は
ext/Rakefile
の 9 行目あたりで設定している。 ↩ -
ゼロから gem を作る場合に,そのほうが作業しすいかもしれない。最初に
bundle gem hoge
とやって gem のプロジェクトを作り,その中に入ってcargo new fuga --lib
とやって Rust のプロジェクトを作ればいいから。 ↩