Kyoto Tycoon+Lua-JIT拡張+MessagePack=無敵

はじめに

http://fallabs.com/blog-ja/promenade.cgi?id=100

まとめ

データベースサーバ上でのスクリプティング用途ではLuaは最強の一角であり、そしてKCのVisitorインターフェイスとの相性も抜群である。TTの時代にもかなり面白いことができたが、KTになってからさらに便利に使えるようになった。ぜひぜひ、お試しあれ。

2年前の作者の記事ですが,まさに面白くてたまりません.

今回は,さらに簡単に性能を上げる実験をやってみましょう.

LuaLua-JITでは,どうもLua-JITの方が性能が良いようなので,
Lua v.s. Lua-Jit - なぜか数学者にはワイン好きが多い
Lua-JIT対応のKyoto Tycoonを作ります.これは,Luaの代わりにLua-JITをリンクするだけで作ることができます.
その上で,Kyoto Tycoonを簡単に拡張してみます.

Kyoto Tycoonでは,Luaスクリプティング拡張をかぶせるだけで,基本的なKVSのSet/Getすらあっさりと機能拡張できます.

まずは手早く実験するために,性能は余り気にせず生産性の高さのためにRubyを使います.
RubyのKyoto Tycoonバインディングは,オフィシャルにサポートされています.というのはウソで,ソケット通信やHTTP通信でアクセスができるので,Rubyじゃなくてもオフィシャルにサポートされていると言えます.
Fundamental Specifications of Kyoto Tycoon Version 1

次に,MessagePackのRubyバインディング.これはいくつか発表されていますが,一番シンプルなgemでインストールできるものを使いました.MessagePackの作者によるものですね.
msgpack | RubyGems.org | your community gem host

Luaに対するMessagePackも沢山ありますが,Lua-JIT対応で他ライブラリへの依存が少ないものを探しました.
幸い,まとめ記事を書いてくれている人がいました.
The state of MessagePack in Lua · GitHub

The state of MessagePack in Lua

So, to sum things up:

  • If you use LuaJIT and are OK with C modules -> lua-msgpack-native
  • If you use PUC Lua 5.1 and are OK with C modules -> lua-cmsgpack
  • If you want pure LuaJIT -> luajit-msgpack-pure
  • If you want pure Lua 5.1 -> lua-msgpack

Lua-JITで依存が少ないということで,私はluajit-msgpack-pureを使いました.

Kyoto TycoonのSet/Get命令のスクリプトによる乗っ取り

Kyoto Tycoon(バックエンドはKyoto Cabinet)は,基本的にキーもバリューも文字列だろうがバイナリデータだろうが何でも突っ込めます.単純な文字列-文字列のKVSじゃなくて,配列やハッシュなどの構造を持ち込みたい,通常はJSONなんかにシリアライズして突っ込むのですが,何分,JSONは空間効率も時間効率も悪いです.JSONにバイナリを持ち込んだBSONも,特に効率が良いわけでは無かったというのが私の結論.
JSON/BSON/MessagePack 処理速度・データサイズ完全比較 - なぜか数学者にはワイン好きが多い

そこで,getもsetもLuaで書いてしまいます.
通信プロトコルも,頑張ってHTTPじゃなくてバイナリプロトコルで行きます.

とは言っても,途中のプロトコルはgetやsetを処理するLuaスクリプトには関係が無いので,まず順にget/setルーチンをさらけ出してみます.

  • 通常のKyoto Tycoon Serialize/Deserializeによるget/set
-- set a record by Kyoto serializer
function set(inmap, outmap)
    -- Bulkではなく,Key/Valueのペアを一組ずつsetすることを仮定
   local key, value = next(inmap)
   local err = false
   local serial = kt.mapdump(inmap)
   -- visitor function
   local function visit(key, value, xt)
      return serial
   end
   -- perform the visitor atomically
   if not db:accept(key, visit) then
      kt.log("error", "inserting a record failed")
      err = true
   end
   if err then
      return kt.EVEINTERNAL
   end
   return kt.RVSUCCESS
end

-- get a record by Kyoto deserializer
function get(inmap, outmap)
   -- getは,固定文字列keyに対するvalueがkey名と仮定
   local key = tostring(inmap.key)
   if not key then
      return kt.RVEINVALID
   end
   local serial = db:get(key)
   if not serial then
      return kt.RVELOGIC
   end
   local rec = kt.mapload(serial)
   for rkey, rvalue in pairs(rec) do
      outmap[rkey] = rvalue
   end
   return kt.RVSUCCESS
end

普通にHTTPのASCIIプロトコルでアクセスすることもできます.上記のプログラムをkt_ex1.luaというファイル名で保存するとすると,Kyoto Tycoonサーバの立ちあげはこんな感じです.

ktserver -ls -scr kt_ex1.lua data.kch

cURLでアクセスしてみます.setはこんな感じ.

> curl "http://192.168.0.100:1978/rpc/play_script?name=set&_abcde-key1=abcde-value1"

「abcde-key1」がキーで,「abcde-value1」がバリューです.getはこんな感じ.

> curl "http://gauss:1978/rpc/play_script?name=get&_key=abcde-key1"
_abcde-key1     abcde-value1

はい,引数で渡したキー「abcde-key1」と,それに対するバリューがタブ区切りで飛んできました.

-- set a record by String serializer
function setraw(inmap, outmap)
    -- Bulkではなく,Key/Valueのペアを一組ずつsetすることを仮定
   local key, value = next(inmap)
   if not key then
      return kt.RVEINVALID
   end
   local err = false
   local serial = value
   -- visitor function
   local function visit(key, value, xt)
      return serial
   end
   -- perform the visitor atomically
   if not db:accept(key, visit) then
      kt.log("error", "inserting a record failed")
      err = true
   end
   if err then
      return kt.EVEINTERNAL
   end
   return kt.RVSUCCESS
end

-- get a record by String deserializer
function getraw(inmap, outmap)
   -- getは,固定文字列keyに対するvalueがkey名と仮定
   local key = tostring(inmap.key)
   if not key then
      return kt.RVEINVALID
   end
   local serial = db:get(key)
   if not serial then
      return kt.RVELOGIC
   end
   local rec = serial
   outmap.value=tostring(rec)
   return kt.RVSUCCESS
end

普通にHTTPのASCIIプロトコルでアクセスすることもできます.上記のプログラムをkt_ex2.luaというファイル名で保存するとすると,Kyoto Tycoonサーバの立ちあげはこんな感じです.

ktserver -ls -scr kt_ex2.lua data.kch

cURLでアクセスしてみます.setはこんな感じ.

> curl "http://192.168.0.100:1978/rpc/play_script?name=setraw&_abcde-key1=abcde-value1"

「abcde-key1」がキーで,「abcde-value1」がバリューです.getはこんな感じ.

> curl "http://gauss:1978/rpc/play_script?name=getraw&_key=abcde-key1"
_abcde-key1     abcde-value1

はい,引数で渡したキー「abcde-key1」と,それに対するバリューがタブ区切りで飛んできました.

-- set a record by MessagePack
function setmp(inmap, outmap)
    -- Bulkではなく,Key/Valueのペアを一組ずつsetすることを仮定
   local key, value = next(inmap)
   if not key then
      return kt.RVEINVALID
   end
   local err = false
   local serial = value
   -- visitor function
   local function visit(key, value, xt)
      return serial
   end
   -- perform the visitor atomically
   if not db:accept(key, visit) then
      kt.log("error", "inserting a record failed")
      err = true
   end
   if err then
      return kt.EVEINTERNAL
   end
   return kt.RVSUCCESS
end

-- get a record by MessagePack deserializer
function getmp(inmap, outmap)
   -- getは,固定文字列keyに対するvalueがkey名と仮定
   local key = tostring(inmap.key)
   if not key then
      return kt.RVEINVALID
   end
   local serial = db:get(key)
   if not serial then
      return kt.RVELOGIC
   end
   local rec = serial
   outmap[key]=tostring(rec)
   return kt.RVSUCCESS
end

実はMessagePackの処理は,クライアント側で行うことを想定しているので,setraw/setmpとgetraw/getmpは,全く同一です.
通常のget/setのみが,シリアライズ/デシリアライズの処理をサーバ側で行なっています.

バイナリプロトコルによるソケット通信アクセス

  • 通常のKyoto Tycoonアクセスの場合

Rubyによるプログラムは,例えば次のようになるでしょう.

require "socket"

# setするバリューデータを作る
b = [];
(1..4000).each{|item|
 b << item;
}

start = Time.now();

(1..10000).each{|i|
  s = TCPSocket.open("192.168.1.100", 1978);

  # Set by Kyto Tycoon serializer
  out = [0xb4].pack("c*") + [0, "set".size(), 1].pack("N*") + "set"\
   + ["key#{i}".size(), Marshal.dump(b).size()].pack("N*") + "key#{i}" + Marshal.dump(b); # Marshalで配列をシリアライズする

  s.write(out);
  s.close();
  # Get by Kyoto Tycoon deserializer
  out = [0xb4].pack("c*") + [0, "get".size(), 1].pack("N*") + "get"\
    + ["key".size(), "key#{i}".size()].pack("N*") + "key" + "key#{i}";

  s = TCPSocket.open("192.168.1.100", 1978);
  s.write(out);
  g =  s.read(1);
  if g.getbyte(0) == 0xb4 then
    rnum = s.read(4).unpack('N*')[0];
    ksize = s.read(4).unpack('N*')[0];
    vsize = s.read(4).unpack('N*')[0];
    key = s.read(ksize);
    value = Marshal.load(s.read(vsize)); # Marshalで配列をデシリアライズする
    s.close();

    # たまに目視確認する
    if i % 1000 == 0 then
      puts "set value=#{b}"
      puts "get value=#{value}";
    end
  end;
}
puts "time = #{Time.now()-start}";
require "socket"

# setするバリューデータを作る
b = [];
(1..4000).each{|item|
 b << item;
}

start = Time.now();

(1..10000).each{|i|
  s = TCPSocket.open("192.168.1.100", 1978);

  # RubyのStringクラスは,JavaのbyteやCのcharのように,バイナリを扱えるので全部Stringにしちゃう

  # Set by String
  out = [0xb4].pack("c*") + [0, "setraw".size(), 1].pack("N*") + "setraw"\
   + ["key#{i}".size(), b.to_s.size()].pack("N*") + "key#{i}" + b.to_s

  s.write(out);
  s.close();

  # Get by String
  out = [0xb4].pack("c*") + [0, "getraw".size(), 1].pack("N*") + "getraw"\
    + ["key".size(), "key#{i}".size()].pack("N*") + "key" + "key#{i}";

  s = TCPSocket.open("192.168.1.100", 1978);
  s.write(out);
  g =  s.read(1);
  if g.getbyte(0) == 0xb4 then
    rnum = s.read(4).unpack('N*')[0];
    ksize = s.read(4).unpack('N*')[0];
    vsize = s.read(4).unpack('N*')[0];
    key = s.read(ksize);
    value = s.read(vsize);
    s.close();

    # ここの目視確認ではset valueとget valueは同一に見えるが,get valueは文字列なので,
    # 実際に配列として使う場合は,value.splitなどで配列に変換する必要がある
    if i % 1000 == 0 then
      puts "set value=#{b}"
      puts "get value=#{value}";
    end
  end;

}

puts "time = #{Time.now()-start}";
  • MessagePackしちゃうバージョン
require "msgpack";
require "socket"

# setするバリューデータを作る
b = [];
(1..4000).each{|item|
 b << item;
}


start = Time.now();

(1..10000).each{|i|

  s = TCPSocket.open("192.168.1.100", 1978);
  # Set by MessagePack serializer
  out = [0xb4].pack("c*") + [0, "setmp".size(), 1].pack("N*") + "setmp"\
   + ["key#{i}".size(), MessagePack::pack(b).size()].pack("N*") + "key#{i}" + MessagePack::pack(b)

   s.write(out);
   s.close();

   # Get by MessagePack deserializer
   out = [0xb4].pack("c*") + [0, "getmp".size(), 1].pack("N*") + "getmp"\
    + ["key".size(), "key#{i}".size()].pack("N*") + "key" + "key#{i}";

  s = TCPSocket.open("192.168.1.100", 1978);
  s.write(out);
  g =  s.read(1);
  if g.getbyte(0) == 0xb4 then
    rnum = s.read(4).unpack('N*')[0];
    ksize = s.read(4).unpack('N*')[0];
    vsize = s.read(4).unpack('N*')[0];
    key = s.read(ksize);
    value = MessagePack::unpack(s.read(vsize));
    s.close();

    # たまに目視確認する
    if i % 1000 == 0 then
      puts "set value=#{b}"
      puts "get value=#{value}";
    end
  end;

}

puts "time = #{Time.now()-start}";

ベンチマーク結果

3つは本当に良く似ています.
通常バージョンとMessagePackバージョンは,RubyのMarshalを使うかMessagePackを使うかの違いでシリアライズ/デシリアライズしてgetしたら配列としてすぐに使える形ですし,StringバージョンとMessagePackバージョンはサーバ側プログラムが同一です.また,StringバージョンのクライアントプログラムをKyoto Tycoonシリアライズのサーバプログラムに突っ込んでもそのまま動きます.

にも関わらず,性能差が出ますので紹介します.

比較は,バリューの長さを変化させたときの,実行時間とHash KVSファイルのサイズを見ることにします.
まずは実行時間です.

MessagePackバージョンがダントツで速いです.というからには,原因はファイルサイズによるのでしょうと思って見てみますと...

やはりMessagePackバージョンのファイルサイズが小さいです.

当然,測定にはバイナリプロトコルスクリプト実行を使いました.cURLwgetは使っていません.

恐らく,JavaC++でマルチスレッドアクセスをすると,もっと差が出ることでしょう.