C拡張のある gem を作れるようになる

はじめに

fukuokarb.connpass.com この記事は上記イベント会場で書いています。 Speee さん、 Fukuoka.rb の皆様ありがとうございます。

そういえばC拡張のgemって作ったことないなと思ったので作りました。RubyKaigi いくとなんかこういうことやりたくなりますよね。

https://github.com/hkdnet/fibc

このエントリではC拡張の gem ってどうなってるんだっけの解説、というほど高尚なものでもないですが今回認識したことを書きます。

C拡張を動かす

今回はCで複雑なロジックを書くことは考えず、フィボナッチ数列の n 番目の数を返すメソッドを追加することにする。つまりこんなメソッドを追加することを目的とする。

Fibc.fib(5) # => 8

bundle gem --ext fibc で雛形ができる。 --ext でC拡張用のファイルも作ってくれる。gemspec にTODOとかが追加されているので、以下それを解消しているものとして扱う。

ファイル構成はだいたいこんな感じ。

.
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
│   ├── console
│   └── setup
├── ext
│   └── fibc
│       ├── extconf.rb
│       ├── fibc.c
│       └── fibc.h
├── fibc.gemspec
├── lib
│   ├── fibc
│   │   └── version.rb
│   └── fibc.rb
└── spec
    ├── fibc_spec.rb
    └── spec_helper.rb

実は Rakefile にもそういう定義が追加されている。さっと確認すると compile などという単語が見え、:default のタスク定義にも compile が追加されているので、ビルドしてからテストするような感じになることがわかる。

$ bundle exec rake -T
rake build            # Build fibc-0.1.0.gem into the pkg directory
rake clean            # Remove any temporary products
rake clobber          # Remove any generated files
rake compile          # Compile all the extensions
rake compile:fibc     # Compile fibc
rake install          # Build and install fibc-0.1.0.gem into system gems
rake install:local    # Build and install fibc-0.1.0.gem into system gems without net...
rake release[remote]  # Create tag v0.1.0 and build and push fibc-0.1.0.gem to TODO: ...
rake spec             # Run RSpec code examples
# Rakefile
require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

require "rake/extensiontask"

task :build => :compile

Rake::ExtensionTask.new("fibc") do |ext|
  ext.lib_dir = "lib/fibc"
end

task :default => [:clobber, :compile, :spec]

gemspec の定義にも spec.extensions = ["ext/fibc/extconf.rb"] という設定があり、gem install 時にどう build すればよいのかを示していることがわかる。extconf.rb については後述する……予定だったがまあ実際 makefile を生成してるっぽいことくらいしかわからなかった。生成後の結果は tmp 配下に吐かれるので確認可能。

rake compile すると lib/fibc/fibc.bundle というファイルができる。これがC拡張をコンパイルしたもの。 最初 gem のエントリポイントである lib/fibc.rbrequire 'lib/fibc/fibc' と書いてあって、そんなのないじゃんって思ってたのだけど、コンパイルしたものを require している。

ここまでわかったので、あとは ruby/ruby のコードを参考にしながら雑にメソッドを追加してみる。C言語において Ruby のモジュールを定義しているところを適当に参考にしながらやればよい。RubyKaigi で大人気だった RubyVM::AbstractSyntaxTree.of の定義している箇所を参考にする。ちょうどよく module に1引数のシングルトンメソッドを定義するという意味で非常に似通っている。

ruby/ast.c at 03c6cb5e8f503dcf6bc80a70a48a9775bbd2a47e · ruby/ruby · GitHub

cruby の世界では、Rubyのオブジェクトはすべて VALUE 型であり、また Ruby のメソッドは必ず戻り値をもつ。なので (VALUE self, VALUE n) -> VALUE な関数を定義してそれを使うことにする。とりあえずステップバイステップに実装するために、引数をそのまま戻す関数を定義してみる。

diff --git a/ext/fibc/fibc.c b/ext/fibc/fibc.c
index 71788c7..4beb8a1 100644
--- a/ext/fibc/fibc.c
+++ b/ext/fibc/fibc.c
@@ -2,8 +2,15 @@
 
 VALUE rb_mFibc;
 
+static
+VALUE fibc_fib(VALUE self, VALUE n)
+{
+    return n;
+}
+
 void
 Init_fibc(void)
 {
   rb_mFibc = rb_define_module("Fibc");
+  rb_define_singleton_method(rb_mFibc, "fib", fibc_fib, 1);
 }

テストは適当に書いて動作確認おk1。 あとは適当にCで書いた関数を使いつつ FIX2INT, INT2FIX を使いながらCの世界とRubyの世界のオブジェクトを変更しておけば動く。

せっかくなのでベンチマークをとってみる。手元のマシンでやった適当な結果ですけどやっぱはやい。

$ cat bench.rb
require 'benchmark/ips'
require 'fibc'

Benchmark.ips do |x|
  # These parameters can also be configured this way
  x.time = 5
  x.warmup = 2

  # Typical mode, runs the block as many times as it can
  x.report("naive") { Fibc.naive(10) }
  x.report("fib") { Fibc.fib(10) }

  # Compare the iterations per second of the various reports!
  x.compare!
end

$ bundle exec ruby bench.rb
Warming up --------------------------------------
               naive    19.611k i/100ms
                 fib   223.955k i/100ms
Calculating -------------------------------------
               naive    195.295k (± 8.4%) i/s -    980.550k in   5.061215s
                 fib      4.174M (± 9.1%) i/s -     20.828M in   5.043693s

Comparison:
                 fib:  4174204.0 i/s
               naive:   195295.2 i/s - 21.37x  slower

動いてC拡張すごい、という気持ちになったところでおわり。ここまでは特に難しくなかったですね。

C拡張書くときに何が使えるのかとかがわからなかったり、Rubyでの処理がCのどの関数呼べばいいのかとかがわからないので次はもうちょい実践的な何かをチャレンジしていきます。

おまけ

objdump するとダミーシンボル的なものがあることがわかる(わからない(読めない

$ objdump -no-show-raw-insn -arch-name x86-64 -macho -x86-asm-syntax intel -D lib/fibc/fibc.bundle 
lib/fibc/fibc.bundle:
(__TEXT,__text) section
_Init_fibc:
     ec0:   push    rbp
     ec1:   mov rbp, rsp
     ec4:   lea rdi, [rip + 213] ## literal pool for: "Fibc"
     ecb:   call    0xf5c ## symbol stub for: _rb_define_module
     ed0:   mov qword ptr [rip + _rb_mFibc], rax
     ed7:   lea rsi, [rip + 199] ## literal pool for: "fib"
     ede:   lea rdx, [rip + _fibc_fib]
     ee5:   mov ecx, 1
     eea:   mov rdi, rax
     eed:   pop rbp
     eee:   jmp 0xf62 ## symbol stub for: _rb_define_singleton_method
     ef3:   nop word ptr cs:[rax + rax]
     efd:   nop dword ptr [rax]
_fibc_fib:
     f00:   push    rbp
     f01:   mov rbp, rsp
     f04:   mov rdi, rsi
     f07:   call    0xf68 ## symbol stub for: _rb_fix2int
     f0c:   mov edi, eax
     f0e:   call    _fibc_fib_int
     f13:   cdqe
     f15:   lea rax, [rax + rax + 1]
     f1a:   pop rbp
     f1b:   ret
     f1c:   nop dword ptr [rax]
_fibc_fib_int:
     f20:   push    rbp
     f21:   mov rbp, rsp
     f24:   push    r14
     f26:   push    rbx
     f27:   mov r14d, 1
     f2d:   cmp edi, 2
     f30:   jl  0xf53
     f32:   mov ebx, edi
     f34:   add ebx, 2
     f37:   mov r14d, 1
     f3d:   nop dword ptr [rax]
     f40:   lea edi, [rbx - 3]
     f43:   call    _fibc_fib_int
     f48:   add r14d, eax
     f4b:   add ebx, -2
     f4e:   cmp ebx, 3
     f51:   jg  0xf40
     f53:   mov eax, r14d
     f56:   pop rbx
     f57:   pop r14
     f59:   pop rbp
     f5a:   ret

  1. 見たければコミットを追ってください