概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Writing C and Sharing Memory... in Ruby!
- 原文公開日: 2017/12/27
- 著者: Piotr Szmielew
- サイト: Rebased.pl
日本語タイトルは内容に即したものにしました。
楽しい画像はすべて英語記事からの引用です。
RubyでインラインCコードを書いてメモリ共有&爆速化してみた(翻訳)
はじめに
「科学の名において無茶する」、今回のエピソードではRubyでC拡張を書く方法を見てみることにします。さほど害はなさそうなので、Rubyプロセス間でメモリを共有して相互通信するところまでやってみます。もちろんプロセスの分離、forkの分離など何でもござれです。
「なぜそんなことを?どうしてどうして?」と言われそうですね。もちろん『科学のために:コノバケモノメ』。
訳注: ゲーム『Portal 2』の有名なセリフだそうです。
免責条項: 本記事では、今後変更される可能性のあるAPIに基づいたコードを多用していますので、productionでは使わないでください、絶対に。絶対に絶対にですよ。何が起きてもおかしくありません。かわいい子犬を死なせたくはないでしょう。確かに警告しましたからね。
はじめの一歩: 例から始めよう
Rubyコード内でCのコードを使う場合、FFIやRiceなどさまざまな方法が考えられますが、中でも最もシンプルなのは今回のネタに仕込んだ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言語で書きたい理由って?
この一言に尽きます。
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標準)でプロセス間メモリ共有を行う方法はいくつかあります。ここではshmget
とshmat
によるアプローチを採用しました。これは最もシンプルかつ今回のユースケースにぴったりでした。
最初に、共有メモリセグメントに名前を付ける方法が必要です。ここでは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
でございまーす!
訳注: このネタ画像については週刊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で双方向にやりとりを行えるようになりました。
オブジェクトを渡せるか?
今度は本記事で最もトリッキーな部分です。ある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)