Ruby / Rust linkage (3) Numerical calculation with FFI

Series of articles table of contents

Introduction

Let's try calling a Rust function that performs simple numerical calculations from Ruby using FFI (Foreign Function Interface). There are already multiple articles with such a theme, so it's the Nth brew.

In addition, it should be noted.

It was used.

Subject

Anyway, I want to make something more practical than the Fibonacci number. Alright, let's do that. It is a 3-variable version of Math. # Hypot. It can be called a three-dimensional version.

The strangely named module function Math.hypot is, in essence,

\sqrt{ x^2 + y^2 }

The one that calculates. It has this name because it calculates the length of the hypotenuse from the lengths of two orthogonal sides of a right triangle, $ x $ and $ y $.

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

The three-variable version of this is, in short

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

It is a function that calculates.

Motivation

Talk about why you want a 3-variable version of Math.hypot. It has nothing to do with the subject of the article, so you can skip to the next section.

This function can be used to get the distance between two points given two coordinates on a plane. In other words

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

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

And so on. Well, if you use Vector

require "matrix"

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

distance = (p1 - p2).norm

I can go, though.

The story went awry.

Then, when it comes to the distance between two points in 3D space, we naturally want a 3D version of hypot. No, of course, using Vector makes it easy to write any dimension as described above, but there may be times when you don't want to use Vector for speed or other reasons.

3 Variable version

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

Can be easily defined. By the way, the reason for using x * x instead of x ** 2 is that the former is very slow [^ square].

[^ square]: With recent square optimizations, it's likely that x ** 2 will not be too slow around Ruby 3.0.

However, the above method has 3 multiplications, 2 additions, and 1 sqrt, and since these are all method calls, the methods are called a total of 6 times. Such expressions may be faster if rewritten in Rust or C.

Implementation: Rust side

I'll write it so that even people who aren't familiar with Rust can reproduce it. However, it is assumed that Rust has already been installed.

Project creation

First at the terminal

cargo new my_ffi_math --lib

And make one Rust project. my_ffi_math is the project name. The option --lib specifies" create a library crate ". A crate is a unit of compilation,

There are two types.

Editing Cargo.toml

There is a file called Cargo.toml at the root of the project. Various settings related to the entire project are written here. At the end of this file

Cargo.toml


[lib]
crate-type = ["cdylib"]

I will add. The meaning of this is not well understood by the author.

Creating a function

There should be a file called src / lib.rs. The test code template is written here [^ test], but you can delete it.

[^ test]: Rust allows you to write test code in your code, and running tests is very easy, just do cargo test.

And

src/lib.rs


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

Write.

A keyword where fn represents the definition of a function. pub and ʻexternare bonuses for that, uh, I can't explain them properly. I thinkpub` is something like" I'll expose this function to the outside world ".

f64 represents a type called 64-bit floating point number, which corresponds to Ruby's Float through FFI. -> represents the return type of the function. The contents of the function can be understood by looking at it. Before the function definition

#[no_mangle]

I'm curious. I'm not sure, but if I don't write this, it seems that even though I defined the function with the name hypot3, I can't refer to it by that name after compilation.

That's all for the implementation on the Rust side.

compile

In the project root directory

cargo build --release

If you do, it will be compiled. build build </ ruby> is a cool way to compile (? No, maybe not) There are two ways to build, one for debugging and one for release (production), and --release literally means to build for release. Execution speed is slow for debugging.

The compiled version should be in the path target / release / libmy_ffi_math.dylib. Oh no, the extension of the file name is Iloilo. When I did it on macOS, it became .dylib, but it should be different on Windows [^ libext].

[^ libext]: To be precise, I think the extension of the product depends not on which OS it was compiled on, but on which target it was compiled. In other words, if you compile it for macOS (x86_64-apple-darwin) on Windows, it will be .dylib. Rust is easy to cross-compile.

This is the only file that Ruby needs.

In this case, the base name of the file name (the part excluding the extension) is the project name with lib at the beginning. If you add name to[lib]in Cargo.toml

Cargo.toml


[lib]
name = "hoge"
crate-type = ["cdylib"]

The file name should look like libhoge.dylib.

Implementation: Ruby side

On the Ruby side, use the gem ffi (you can also use fiddle).

Easy with the code below

require "ffi"

But of course if you manage it with Gemfile

Gemfile


gem "ffi", "~> 1.13"

Write it in a script

require "bundler"
Bundler.require

And so on.

Also, let's assume that the Ruby code is in the root directory of the Rust project for the time being.

Write like this.

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

#reference
p Math.sqrt(1 * 1 + 2 * 2 + 3 * 3)
# => 3.7416573867739413

The execution result is also written in the comment, but you can see that the result is the same as the one calculated in Ruby.

It doesn't matter what you set as FFIMath, so I just prepared one module that is something nice. ʻExtend FFI :: Libraryfor this module. This gives rise to some singular methods such asffi_lib`.

The ffi_lib method specifies the path of the compiled library file. Well, I'm not sure, but it seems that relative paths may not work, so it seems better to give an absolute path. To do this, use File.expand_path

  ffi_lib File.expand_path("target/release/libmy_ffi_math.dylib", __dir__)

And. If you write like this, the relative path from the location (__dir__) of this file will be an absolute path.

ʻAttach_functioncreates a function created in Rust as a singular method of a module. The first argument is the function name. The second argument specifies the type of function argument. Since it has 3 arguments, it is an array of length 3.: double` represents a FFI double precision floating point number. It corresponds to f64 in Rust and Float in Ruby. The third argument specifies the return type of the function. With the above, the singular method of the module can be defined.

As you can see how to use it. If you have a good idea, you may be wondering, "Hmm? You have to give Float to the argument, but you're giving an Integer?" I'm not familiar with this, but I'm sure ffi gem has converted it to floating point numbers.

It was surprisingly easy!

Benchmark test

I was hoping for speed when I tried to implement hypot3 in Rust. Then we have to prove it by benchmark test. Well, how fast will it be!

Test library

When measuring light processing such as hypot3, I think the benchmark test library is benchmark_driver.

In order to measure light processing, it is necessary to measure the time when the same processing is executed many times, but if you run a loop with the times method etc., the cost of the loop cannot be ignored relatively, so it cannot be measured accurately .. Benchmark_driver runs many times without incurring such costs, so it seems that the actual execution speed can be measured (I don't know how it works).

Test code

How to use benchmark_driver

There are two ways, but this time I will try the latter. The latter requires you to remember the YAML format, but it's not that difficult.

Write as follows.

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)

For the contents of prelude, write something that should be done before measurement. I have defined Math.hypot3 for comparison.

Test run

If you can do this, at the terminal

benchmark-driver benchmark.yaml

And. (It is confusing that the command name is hyphen even though the gem name is underscore) Oh, install benchmark_driver gem

gem i benchmark_driver

Let's do it in advance.

Result is···

Comparison:
   Math.hypot3(x, y, z):  10211285.3 i/s
FFIMath.hypot3(x, y, z):   5153872.8 i/s - 1.98x  slower

eh?

Math.hypot3 can run 10 million times per second, while FFI Math.hypot3 can run 5 million times per second. Isn't it a terrible defeat? Far from being fast, it's too slow to talk about [^ osoi].

[^ osoi]: By the way, in this code, Integer objects are given to x, y, z, but when a Float object is given, Math.hypot3 slows down by more than 10%. The FFIMath.hypot3 was unchanged.

What is the cause of the defeat? Rust's code doesn't seem to improve anymore. For compilation, I specified the release build properly. Looking at various benchmarks of Rust, it seems that it is not inferior to C, so it seems that Rust is not slow.

When it comes to that, isn't it the cost of FFI? Since Ruby can pass anything as an argument, I suspect that the ffi gem will check and convert the type at runtime. Such extra (?) Processing may be a burden.

It turned out that there seems to be no point in implementing processing of about hypot3 in Rust. We have to do heavier processing.

Let's take a second look and think about something "heavier processing".

Recommended Posts

Ruby / Rust linkage (3) Numerical calculation with FFI
Ruby / Rust linkage (4) Numerical calculation with Rutie
Ruby / Rust linkage (5) Numerical calculation with Rutie ② Bezier
Ruby / Rust linkage (6) Extraction of morphemes
Object-oriented numerical calculation
Conditional numerical calculation
Install Ruby 3.0.0 with asdf
Ruby / Rust cooperation (1) Purpose
Ruby score calculation program
Getting Started with Ruby
Ruby calorie calculation output
Ruby / Rust cooperation (2) Means
11th, Classification with Ruby
Evolve Eevee with Ruby