Ruby / Rust integration (7) Create Rust extension gem built at installation

Series of articles table of contents

Introduction

This time, an example of a gem with Rust code in the source. The following articles will be helpful. Easy to make with Rust Ruby Gem --Qiita

In the above article, Rust was compiled during development and the product was included in the gem package. On the other hand, let's take ** how to compile (build) Rust when installing gem **. To do this, of course, Rust must be installed in the installation environment.

We will expose the prototype gem to GitHub. The gem name is rust_gem_sample01. https://github.com/scivola/rust_gem_sample01

It's a prototype to show how to do it, so I haven't included it in RubyGems.org.

I intended to install it on macOS, Linux, and Windows.

Subject

This gem is not for practical purposes, so a simple example. Ruby's built-in methods and standard attached libraries do not have a random number generation function that has a distribution other than a uniform distribution, so let's do that. For the time being, the one that returns a random number with a normal distribution.

Things that can be used like this:

require "rust_gem_sample01"

standard_deviation = 2.0

p RustGemSample01.rand_norm(standard_deviation)
# => -3.1674786173729506

Define singular method rand_norm in RustGemSample01 module. If the standard deviation is given to the argument, a random number with an average value of 0 is returned. The normal distribution has two parameters, the mean value and the standard deviation, but if you want to change the mean value, you only have to add them, so the argument is only the standard deviation. The more arguments there are, the higher the cost of FFI, so I think this is fine.

Let's install

It is not listed in RubyGems.org, so

gem install rust_gem_sample01

Cannot be installed.

Follow the steps below to clone the repository and build and install the gem. I would like to know if it can be installed properly in various environments, so I would appreciate your cooperation.

git clone https://github.com/scivola/rust_gem_sample01.git
cd rust_gem_sample01
rake install

This final rake install is to create a gem package from the gem's project file [^ mkpkg] and install the gem on your system (ie globally).

[^ mkpkg]: The gem package is created by another Rake task called build, but since the ʻinstall task depends on the buildtask, justrake install` will do both.

In order to do the above, Git must be installed and Rust (version 1.40.0 or higher) must be installed [^ rust1_40].

[^ rust1_40]: There is absolutely no reason to set the version of Rust to 1.40.0 or higher. There should be no problem with a lower version, but I don't know what happened with which version, so I tried to make it a little older than the latest version (1.46.0 at the moment). The lower limit of the version is set around the 9th line of ʻext / Rakefile`.

If the installation fails, I think it's about building the Rust code or moving the build product.

Rust is compiled at the time of installation, but it may take several tens of seconds in some cases because it also downloads dependent crates from the net. Please wait patiently even if there is no response.

For sudo gem install

For Linux, when installing Ruby system-wide and installing gems

sudo gem install hoge

I think you often have to add sudo like this. In this case, cargo must also work with sudo. Rust

Let's use

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

This draws a histogram (frequency distribution map) of a normal distribution random number with a mean of 0 and a standard deviation of 2.5 generated 10000 times and rounded to an integer. Generally, something like this is output.

 -11
 -10
  -9
  -8
  -7 *
  -6 **
  -5 *****
  -4 ***********
  -3 ******************
  -2 ******************************
  -1 **********************************
   0 ************************************
   1 **********************************
   2 *****************************
   3 ******************
   4 ***********
   5 ******
   6 **
   7 *
   8
   9
  10

Yeah, it looks like that.

If the program doesn't work and you get an error, please let me know.

file organization

.
├── 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

By the way, this figure was made with the command line tool exa made by Rust.

A mixture of files for gems and files for Rust packages

As you can see, the files for gems and the files for the Rust package are mixed in the same hierarchy. To briefly explain, the files around bin, lib, pkg, Rakefile, rust_gem_sample01.gemspec, test are gem. Cargo.toml, src are Rust package files. If you build Rust, you can also create a target directory.

This is fine because the file name and directory name are not covered, but I'm not sure if this is a good style. Rutie's sample had this kind of structure (I think). At least at first glance, I don't really think that it can be distinguished immediately. You might want to push the Rust relationship into one directory [^ files].

[^ files]: If you're building a gem from scratch, it might be easier to work with. First you can do bundle gem hoge to create a gem project, then go inside and do cargo new fuga --lib to create a Rust project.

Rust code

The Rust code is a package with only the library crate rand_distr_for_ruby. Dependent crate

Cargo.toml


[dependencies]
rand = "0.7.3"
rand_distr = "0.3.0"

Only. rand is familiar. rand_distr is a distribution such as normal distribution, Cauchy distribution, binomial distribution, Poisson distribution, etc. that was originally included in rand. It seems that the function that generates the random number of is independent (I don't know).

This is the only code body:

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())
}

It seems useless to generate rand_distr :: Normal every time it is called. Well, it's not for practical purposes.

If you want to generate a large number of random numbers with the same distribution, you want to do this only once. It's easy to keep the generated rand_distr :: Normal all the time with Rutie (see [(5)](see https://qiita.com/scivola/items/2ab41a03beb00f6ce3d2)), but only with FFI. I'm not sure how to do it.

By the way, during development, in this state

cargo check

But

cargo build --release

I can do it. The advantage of this directory structure is that you don't have to go back and forth between hierarchies by putting Rust files in the root directory of your project.

Ruby code

This is the core code on the Ruby side.

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

I'm using a gem called ffi to assign Rust functions to Ruby methods. FFI (Foreign Function Interface), which makes it easy to handle a mechanism for exchanging between different languages.

Most of the code in this module definition is devoted to determining the arguments to pass to the ffi_lib method. Pass the path of the library file to be read to this method. The library file compiled with Rust has a slightly different file name depending on the OS, so it is complicated like this. I'm not sure if this process really fits, but the Rutie code [rutie-gem / lib / rutie.rb](https://github.com/danielpclark/rutie-gem/blob/ffaba4689f351a0acaf84067a6fca5c3b65bc9aa/lib/rutie. I referred to rb).

attach_function :rand_norm, [:double], :double

The part of is to generate Ruby methods in the module based on the loaded library. The first argument is the name of the library function. This is also the name of the Ruby method. The second argument is the type of the function argument. Since there can be multiple arguments, give them as an array. : double is the name of the type defined by the FFI specification and represents a double precision floating point number. I don't know much about it, but it's probably the C language double. Ruby's Float supports this. The third argument is the return type of the function.

That's all it takes to use the functions of the library created by Rust on the Ruby side through FFI. Easy.

Tailor to gem

gemspec

Here is the place that needs special explanation about gemspec.

rust_gem_sample01/rust_gem_sample01.gemspec


#Excerpt
Gem::Specification.new do |spec|
  #Omission
  spec.add_dependency "ffi", "~> 1.13.1"
  spec.extensions = %w[ext/Rakefile]
end

Since we are using the ffi gem, it is natural to add it to the runtime dependency (ʻadd_dependency`), but the important thing is the next

spec.extensions = %w[ext/Rakefile]

By the way. What is this?

This is the path to the Rake file that describes what to do when installing the gem. In the case of C extensions, the role is usually taken care of by a file called ʻextconf.rb`. For sqlite3 gem, this is sqlite3-ruby / ext / sqlite3 / extconf.rb. I'm not sure at all, but it seems that the C extension compiles C and so on according to this file.

However, it seems that ʻextconf.rb` is premised on C, and I'm not sure how to write it in the case of Rust. Or rather, I felt that it couldn't be used with Rust in the first place. When I searched for other means, I found that there was also a way to use the Rake task. This will be described in detail in the next section.

Rakefile

It seems that what you wrote as a default task in the Rake file specified asspec.extensions =in gemspec will be executed at installation time.

rust_gem_sample01/ext/Rakefile


#What to do when installing gem
#Describe as default task
task :default do
  #Is Rust installed?
  #Determine if the cargo command works
  begin
    cargo_v = `cargo -V`
  rescue Errno::ENOENT
    raise "Cargo not found. Install it."
  end

  #Is the Rust version (matching the Cargo version) above a certain level?
  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

  #Build Rust
  system "cargo build --release", chdir: __dir__ + "/.."

  #Product file name
  #Depends on 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

  #Product lib/Move directly below
  FileUtils.mv __dir__ + "/../target/release/#{file_name}", __dir__ + "/../lib/"
  FileUtils.rmtree __dir__ + "/../target/"
end

I wrote a comment in the code, but what I'm doing is basically

And just this. I'm curious that the place to get the filename of the Rust code build product is lib / rust_gem_sample01.rb and not DRY. Well, it may be okay to move all the files directly under target / release /.

By the way, if you build with Rust, you can create various files in the process of downloading and compiling the necessary libraries. In the case of this gem, a file of about 18 MB is generated at build time. All I want is one file (about 300 KB this time), and everything else is unnecessary.

So the last line of the task

FileUtils.rmtree __dir__ + "/../target/"

Is being cleaned by.

in conclusion

For C extensions, there is debate about whether gems should include compiled files for distribution.

reference:

There are pros and cons, but what about Rust? Perhaps you usually choose to precompile. That's because many Ruby users probably don't have a Rust compilation environment.

So, in this article, I dared to try a method that many people wouldn't do.

Recommended Posts

Ruby / Rust integration (7) Create Rust extension gem built at installation
Create a native extension of Ruby in Rust
Created a native extension of Ruby with Rust and published it as a gem