Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RubyでインラインCコードを書いてメモリ共有&爆速化してみた(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
楽しい画像はすべて英語記事からの引用です。

RubyでインラインCコードを書いてメモリ共有&爆速化してみた(翻訳)

はじめに

「科学の名において無茶する」、今回のエピソードではRubyでC拡張を書く方法を見てみることにします。さほど害はなさそうなので、Rubyプロセス間でメモリを共有して相互通信するところまでやってみます。もちろんプロセスの分離、forkの分離など何でもござれです。

「なぜそんなことを?どうしてどうして?」と言われそうですね。もちろん『科学のために:コノバケモノメ』。

For science, you monster
訳注: ゲーム『Portal 2』の有名なセリフだそうです。

免責条項: 本記事では、今後変更される可能性のあるAPIに基づいたコードを多用していますので、productionでは使わないでください、絶対に。絶対に絶対にですよ。何が起きてもおかしくありません。かわいい子犬を死なせたくはないでしょう。確かに警告しましたからね。

はじめの一歩: 例から始めよう

Rubyコード内でCのコードを使う場合、FFIRiceなどさまざまな方法が考えられますが、中でも最もシンプルなのは今回のネタに仕込んだrubyinlineです。

rubyinlineは普通のgemと同様gem install rubyinlineでインストールできます。それでは早速、Rubyコード内で2+2をC言語で書いてみましょう。

require 'inline'
class CHello
  inline do |builder|
    builder.include '<stdio.h>'
    builder.c 'int sumThem() {
      return 2 + 2;
    }'
  end
end

このシンプルなプログラムを解剖してみましょう。ただし着目するのは本物の「CMeat」の部分です(まったく某ステーキハウスもうまい名前を付けたものです)。ここではすべてのCコードを.rbファイル内で定義しており、inlineという名前のブロック内に配置しています。Cコードは、builder.include呼び出し(このコードは実際にはここでは不要ですが、完全な形式をお見せするためにあえて書いています)と、関数などのコード片を含むbuilder.c呼び出しで構成されます。

さて、その結果は?

>> CHello.new.sumThem #=> 4

やった!動きました。次はパラメータを渡してみましょう。

require 'inline'
class CHello
  inline do |builder|
    builder.include '<stdio.h>'
    builder.c 'int sumThem(int a, int b) {
      return a + b;
    }'
  end
end

今度はどうでしょうか。

CHello.new.sumThem(100, 200) #=> 300

やりました!動いています。素晴らしい、もうRubyなんか二度と使う気になれませんね。しかしちょっと待った、もっとでかい数値を食わせたらどうなるでしょうか。私も答えを知らない「220と230」で試してみると...

CHello.new.sumThem(2**30, 2**30) #=> -2147483648

おっと、C言語でお馴染みのintegerオーバーフローです。アリスならぬRubyワンダーランドをさまようときは、こういう落とし穴を1つも見落とさないようにしないといけませんね。

C言語で書きたい理由って?

この一言に尽きます。

Speed

RubyとC言語のそれぞれで、1からNまでの数値をすべてカウントする同等のコードを書いてベンチマークしてみましょう。

Nには任意のlong(ただし231-1より小さいこと)の数値を設定します(216など)。ベンチマークの秘密兵器にはbenchmark-ipsを使いました。

require 'inline'
require 'benchmark/ips'
class Counter
  inline do |builder|
    builder.include '<stdio.h>'
    builder.c 'int cCount(int n) {
      int counter = 0;
      for(int i = 0; i < n; i++) {
        counter += i;
      }
      return counter;
    }'
  end

  def rCount(n)
    counter = 0
    i = 0
    while i < n
      counter += i
      i += 1
    end
    counter
  end
end

tested_class = Counter.new

Benchmark.ips do |x|
  x.report("C Method") { tested_class.cCount(2**16) }
  x.report("Ruby Method") { tested_class.rCount(2**16) }
  x.compare!
end

結果は、ぶっ飛びのぶっちぎりの大爆速です。

$ ruby sumthem.rb
Warming up --------------------------------------
            C Method   215.194k i/100ms
         Ruby Method    45.000  i/100ms
Calculating -------------------------------------
            C Method      6.306M (±10.3%) i/s -     31.203M in   5.003825s
         Ruby Method    473.080  (± 5.3%) i/s -      2.385k in   5.055964s

Comparison:
            C Method:  6305730.9 i/s
         Ruby Method:      473.1 i/s - 13329.09x  slower

ぶ・っ・飛・び

そしてこれは、パフォーマンスが重要な数値計算をRubyで行うべきでない理由でもあります。もちろんRubyではintegerオーバーフローを気にしないで済む点や、必要に応じてさらに巨大なデータ型に変換できるといった点が便利なのは間違いありません。

神に見捨てられたまいし場所

ここからが本当の修羅です。いたいけな子どもたちが泣き叫び、シスアドが悲鳴を上げて逃げ惑う修羅の地、そう、メモリ共有です。

そのために、まずはCのツールがいくつか必要です。典型的なUnixシステム(POSIX標準)でプロセス間メモリ共有を行う方法はいくつかあります。ここではshmgetshmatによるアプローチを採用しました。これは最もシンプルかつ今回のユースケースにぴったりでした。

最初に、共有メモリセグメントに名前を付ける方法が必要です。ここでは123というキーを使います。これをshmget呼び出しに渡す方法が必要なので、呼び出しはshmget(key, 1024, 0644 | IPC_CREAT)のような感じになります。最初の引数は共有メモリセグメントの一意の識別子、2つ目の引数はその長さです(もちろん私は1 KBにしましたけどね)。3つ目の引数はアクセス制御の指定です。詳しくはIPC:Shared Memoryをご覧ください。

shmgetから受け取るのは別の識別子です。今回はこれをshmatに渡す必要があります。shmatは、プロセスの指定の(または新規の)アドレスに共有メモリをアタッチします。最後の呼び出しは常にshmdtでなければなりません(メモリをデタッチする)。

それではコードを書いてみましょう。覚悟は完了ですか?

require 'inline'
class Sharer
  inline do |builder|
    builder.include '<sys/types.h>'
    builder.include '<sys/ipc.h>'
    builder.include '<sys/shm.h>'
    builder.c 'int getShmid(int key) {
      return shmget(key, 1024, 0644 | IPC_CREAT);
    }'
    builder.c 'int getMem(int id) {
      return shmat(id, NULL, 0);
    }'
    builder.c 'int removeMem(long id) {
      return shmdt(id);
    }'
  end
end

(型変換のせいで警告が少々表示されますが、気にする必要はありません)。

これで共有メモリの作成とアタッチの準備は整いました。ん、後は何が必要でしたっけ?

Johnny Fiddle::Pointerでございまーす!

Hereeeeeee's Johnny

訳注: このネタ画像については週刊Railsウォッチ(20180112)をどうぞ。

Rubyツールボックスに潜むFiddleはほとんど知られていませんが、非常に鋭利なナイフです。なぜナイフなのかというと、非常に鋭く尖り、切れ味がシャープで、自分をぶっ刺すこともできてしまうからです。でもこういうのが楽しいんですよね!

Fiddleは、CポインタのレベルでRubyとやりとりできる品の良いライブラリです。これこそ今回のユースケースです。何だかゾクゾクしてきました。

ついでに申し上げると、Fiddle::Pointerを使うとRubyオブジェクトをunfreezeすることだってできてしまいます。もちろんとんでもないことですが、このダーティハックをご覧に入れましょう。

str = 'water'.freeze
str.frozen? # true

# Rubyオブジェクトを指すこのCポインタは
# object_idを1ビット左にシフトした(値を倍にした)ものと等しい
memory_address = str.object_id << 1

Fiddle::Pointer.new(memory_address)[1] &= ~8
str.frozen? # false

このコードが動作する理由がおわかりでしょうか。これは、Rubyオブジェクトのfrozenフラグを保持するメモリビットを反転しているのです。良い子の皆さんは真似しないでくださいね。

このメモリ変更は、forkのスコープで使うことにします(この方法はコードで示すときにいちばん楽なので)。このコードは、forkブロックから別のRubyプロセスに移動してもそのまま動作します。

basic_id = Sharer.new.getShmid(123)
address = Sharer.new.getMem(basic_id)
pointer = Fiddle::Pointer.new(address, 1024)

このpointerには、真新しい共有メモリへの参照が含まれています。最初のバイトに何か値を設定してみましょう。私は4が好きなので、4を設定してみます。

pointer[0] = 4

ここからがマジックです。このプロセスをforkし、次にこのセグメントをアタッチしてメモリ空間をforkし(元のプロセスを使えない理由がおわかりでしょうか?)、pointer[0]の値を変更して、元の値が変更されたかどうかをチェックしてみましょう。

pid = fork do
  basic_id = Sharer.new.getShmid(123)
  address = Sharer.new.getMem(basic_id)
  pointer = Fiddle::Pointer.new(address, 1024)

  pointer[0] = 44

  Sharer.new.removeMem(address)
end
Process.wait(pid)
puts pointer[0]

さて、結果は...?

puts pointer[0] # => 44

やりました!ついにforkで双方向にやりとりを行えるようになりました。

Bravo

オブジェクトを渡せるか?

今度は本記事で最もトリッキーな部分です。あるforkしたプロセスのRubyオブジェクトをfork元のプロセスに渡すことができるでしょうか?答えは「できるものとできないものがあります」。単純なオブジェクトなら渡せますが、たとえばハッシュのようなオブジェクトは、内部に参照を多数抱えています(keyやvalueなど)。つまり、こうしたオブジェクトを渡すにはかなりのハックが必要です。しかしシンブルな小さいオブジェクトなら可能です。

ここでご注意いただきたいのは、新しく作成したオブジェクトのメモリを設定できなかったことです。しかしFiddleを使って、あるセグメントから別のセグメントへメモリ全体をコピーすることはできます。そこで、新しいオブジェクトを1つ作成してそのポインタを取得し、そのオブジェクトのメモリ全体を共有メモリにコピーすることにします。

先のコードのforkの部分を変更しましょう。

pid = fork do
  basic_id = Sharer.new.getShmid(123)
  address = Sharer.new.getMem(basic_id)
  pointer = Fiddle::Pointer.new(address, 13) # 13 bytes is enough, tested

  obj = Object.new
  second_pointer = Fiddle::Pointer.new(obj.object_id << 1)
  pointer[0, 13] = second_pointer

  Sharer.new.removeMem(address)
end

Process.wait(pid)
puts pointer.to_value

Sharer.new.removeMem(address)

さてその結果は...

puts pointer.to_value # => #<Object:0x0000010d2c5000>

うまくいきました!

これって何か使い道あるの?

私が思いつける便利なアプリは1つしかありません。任意のRubyオブジェクトを共有メモリに書き出して別のプロセスからこのメモリにアクセスできるとしたら、何らかのCプログラムを使って共有メモリの中を素早く覗き込み、注意深く選んだオブジェクトをRubyからダンプできるでしょう。

そのようなCコードの例を見てみましょう。

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>

int main(void) {
  int basicId = shmget(123, 1024, 0644 | IPC_CREAT);
  char *address = shmat(basicId, 0, 0);
  getchar();
  char *s;
  int i;
  for (s = address+15, i = 0; i < 14; i++) {
  // 典型的な状況では次のように書くのが普通
  // (s = address, i = 0; i < 1024; i++)
  // ただしここではフラグなどを使わずに
  // 文字列の内容だけを表示するようパラメータを調整してある
  // これは別記事向きのトピックですね;)
    putchar(*s);
    s++;
  }
  putchar('\n');

  shmdt(address);
}

簡単のために、ある文字列を共有メモリに書き出すだけのコードを書いてみましょう。

basic_id = Sharer.new.getShmid(123)
address = Sharer.new.getMem(basic_id)
pointer = Fiddle::Pointer.new(address, 1024)

string = "Hello Rebased"
second_pointer = Fiddle::Pointer.new(string.object_id << 1)
pointer[0, 1024] = second_pointer

gets

Sharer.new.removeMem(address)

Cコードをコンパイルします。

gcc --std=c11 dump_me.c -o dump_me.o

それではRubyコードを実行してdumpプログラムを実行してみましょう。「見るがよい!」

$ ./dump_me.o

Hello Rebased

できました。

まとめ

本記事では、RubyコードにCコードをうまく埋め込む方法を見い出し、これが高速化に役立つことを発見しました。そして何よりも、禁断の地である共有メモリを攻略できました。

共有メモリの完全なコード例

require 'inline'
require 'fiddle'

class Sharer
  inline do |builder|
    builder.include '<sys/types.h>'
    builder.include '<sys/ipc.h>'
    builder.include '<sys/shm.h>'
    builder.c 'int getShmid(int key) {
      return shmget(key, 1024, 0644 | IPC_CREAT);
    }'
    builder.c 'int getMem(int id) {
      return shmat(id, NULL, 0);
    }'
    builder.c 'int removeMem(long id) {
      return shmdt(id);
    }'
  end
end

basic_id = Sharer.new.getShmid(123)
address = Sharer.new.getMem(basic_id)
pointer = Fiddle::Pointer.new(address, 1024)

pid = fork do
  basic_id = Sharer.new.getShmid(123)
  address = Sharer.new.getMem(basic_id)
  pointer = Fiddle::Pointer.new(address, 13) # 13 bytes is enough, tested

  obj = Object.new
  second_pointer = Fiddle::Pointer.new(obj.object_id << 1)

  pointer[0, 13] = second_pointer

  Sharer.new.removeMem(address)
end

Process.wait(pid)
puts pointer.to_value

Sharer.new.removeMem(address)

関連記事

メモリを意識したRubyプログラミング(翻訳)

Ruby 3 JITの最新情報: 現状と今後(翻訳)

Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。