LoginSignup
4
3

More than 1 year has passed since last update.

Ruby/Rust 連携 (7) インストール時ビルドの Rust 拡張 gem を作る

Last updated at Posted at 2020-10-18

連記事目次

はじめに

今回は,ソースに 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 だけを持つパッケージとなっている。
依存クレートは

Cargo.toml
[dependencies]
rand = "0.7.3"
rand_distr = "0.3.0"

だけ。
rand はお馴染み。rand_distr のほうは,もともと rand に入っていた,正規分布,コーシー分布,二項分布,ポアソン分布などなどの〈一様でない〉分布の乱数を発生させる関数を独立させたものらしい(知らんけど)。

コード本体はこれだけ:

src/lib.rs
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 側の中核となるコードがこれ。

rust_gem_sample01/lib/rust_gem_sample01.rb
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 について,とくに説明を要するところはここ。

rust_gem_sample01/rust_gem_sample01.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 タスクとして書いたことがインストール時に実行されるらしい。

rust_gem_sample01/ext/Rakefile
# 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 のコンパイル環境を持っていないだろうから。

なので,この記事ではあえて多くの人がやらなさそうな方法を試してみた。

  1. gem のパッケージを作るのは build という別の Rake タスクなのだが,install タスクが build タスクに依存しているので,rake install だけで両方やってくれる。

  2. Rust のバージョンを 1.40.0 以上としたのは全く何の根拠もない。もっと低いバージョンでも何ら問題ないはずだが,どのバージョンで何がどうなったか把握してないので,最新版(現時点で 1.46.0)よりちょっと古いものにしてみた。バージョン下限は ext/Rakefile の 9 行目あたりで設定している。

  3. ゼロから gem を作る場合に,そのほうが作業しすいかもしれない。最初に bundle gem hoge とやって gem のプロジェクトを作り,その中に入って cargo new fuga --lib とやって Rust のプロジェクトを作ればいいから。

4
3
3

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
3