ここまでの流れの整理をします。Thriftの調査の続きです。前回のエントリー(Thriftのスピードが改善しました)では、Thriftのperlクライアントが遅いのはNagleアルゴリズムが原因ではないかという仮説をたて、そしてNagleアルゴリズムをオフにした状態で速度が向上したことを書きました。
それに対し、tokuhiromさんにより投稿されたエントリー(ThriftはThrift::BufferedTransport をつかいわすれると 147 倍遅くなってつかいものにならない)では、send(2)のバッファリングをしていないことが原因で、Thriftが吐き出すデフォルトのperlクライアントのスケルトンでは使われていない、 Thrift::BufferedTransport を使うことによってsend(2)がバッファリングされて、高速になることを示してくれました。加えてkazuhookuさんのご指摘によりread(2)もバッファリングしてないため遅いとのこと。僕の環境でもThrif::BuffredTransporを使うことによって高速化することを確認しました。
そして、「Thriftのスピードが改善しました」のエントリーに対して、はてぶコメントでkazuhookuさんに以下のコメントをいただきました。kazuhookuさん、ありがとうございます!
282QPSってむちゃくちゃ遅いと思う。てか、サーバサイドのロジックがここまで単純なら、多重化してベンチとるべき / これだとパケット流れまくるのでせめて MSG_MORE とか使うべき
http://b.hatena.ne.jp/entry/blog.broomie.net/index.cgi?id=38 より引用
MSG_MOREについて
ご指摘をいただくまで知らなかった&調べてみてわかったのですが、確かにTCP_NODELAYにするのであれば、MSG_MOREを使ったほうが良いみたいですね。冗長で恐縮なのですが、メモの意味も含めて整理すると、TCP_NODELAYというのはmanで以下のように解説されています。
% man tcp
ソケットオプション
TCP ソケットのオプションは、オプションレベル引数に IPPROTO_TCP を指定した setsockopt(2) で設定でき、 getsockopt(2)で取得できる。さらに、ほとんどの IPPROTO_IP ソケットオプションも TCP ソケットに対して有効である。詳細は ip(7)を見よ。
TCP_NODELAY
設定すると Nagle アルゴリズムを無効にする。すなわち、データ量が少ない場合でも各セグメントは可能な限り早く送信される。設定されていないと、送信する分だけ溜まるまでデータはバッファされ、小さなパケットを頻繁に送らずにすみ、ネットワークを有効に利用できる。このオプションは TCP_CORK により上書きされる。しかしながら、 TCP_CORKが設定されている場合であっても、このオプションを設定すると、送信待ちの出力を明示的に掃き出す (flush) ことになる。
以前のエントリーと繰り返しになってしまいますが、上記の引用のようにTCP_NODELAYを有効にすると 「データ量が少ない場合でも各セグメントは可能な限り早く送信される」 わけですね。そうすると、今回の実験のケースのような場合、細かいパケットが通常よりも大量に送信されてしまう可能性があります。そこで、それを防ぐためにせめてMSG_MOREを使ったほうが良いという指摘をうけたのだと思います。MSG_MOREが何なのかというman sendで以下のように解説されています。
% man send
MSG_MORE (Linux 2.4.4 以降)
呼び出し元にさらに送るデータがあることを示す。このフラグは TCP ソケットとともに使用され、TCP_CORKソケットオプションと同じ効果が得られる (tcp(7) を参照)。TCP_CORK との違いは、このフラグを使うと呼び出し単位でこの機能を有効にできる点である。Linux 2.6 以降では、このフラグは UDP ソケットでもサポートされており、このフラグ付きで送信された全てのデータを一つのデータグラムにまとめて送信することを、カーネルに知らせる。まとめられたデータグラムは、このフラグを指定せずにこのシステムコールが実行された際に初めて送信される (udp(7) に記載されているソケットオプション UDP_CORK も参照)。
これだけではわからないので、TCP_CORKも引用しておきます。
% man tcp
TCP_CORK
セットされると、partial フレームを送信しない。このオプションが解除されると、キューイングされたpartialフレームが送られる。これはsendfile(2)を呼ぶ前にヘッダを前置したり、スループットを最適化したい場合に便利である。現在の実装では、TCP_CORKで出力を抑えることができる時間の上限は200ミリ秒である。この上限に達すると、キューイングされたデータは自動的に送信される。Linux 2.5.71 以降においてのみ、このオプションをTCP_NODELAYと同時に用いることができる。移植性の必要なプログラムではこのオプションを用いるべきではない。
この解説の通り、部分的な小さいフレームは送信せずにキューイングしたかたまりを送ってくれるようになります。そして、このフラグはTCP_NODELAYと同時に用いることができます。上記で引用したTCP_NODELAYの解説によるとTCP_CORKによってTCP_DELAYは上書きされてしまい、TCP_MSGが優先されるようですね。しかし、送信待ちの出力を明示的にflushするとなっているが、これがどう振る舞いに影響するのかはもっと詳しく調査してみないと定かではないのでここでは述べないことにします。
MSG_MOREをThrift::Socketで使ってみようと思って、perlライブラリに入ってるSocketライブラリを見てみたのですが、まだMSG_MOREは対応していないみたいでした。おそらくDanga::Socketなどを使えばできそうかもしれませんが、Thrift::Socketを大々的に書きかえる作業は今はやりたくないので今回はあきらめました。
で結局どうしたらいいのか
ここまでの考察で、確信はできないけれどもTCP_NODELAYやMSG_MOREを使うことは場合によっては危うい感じもします。であれば、少しでもリスクになりうるTCP_NODELAYフラグは使わないほうがよいと僕は結論を出しました。というのもtokuhiromさんによって示された、Thrift::BufferedTransportを使ってreadをバッファリングする方法で十分に高速化できることを確認できたためです。
----
多重化してベンチをとる
「282QPSってむちゃくちゃ遅いと思う。てか、サーバサイドのロジックがここまで単純なら、多重化してベンチとるべき」とのご指摘をうけ、「うう、確かにそうだなあ。。」と思い、server(c++) + client(perl + Thrift::BufferedTransport)の構成でクライアントを多重化してベンチをとってみることにしました。
server側をマルチスレッドにする
今回もプログラムは以前のエントリー(Thriftが便利すぎる)で紹介した「足し算・引き算」サーバを使います。この構成ではサーバ側はC++で実装しています。そしてTSimpleServer.hというシングルスレッドのサーバを使っているため、このままの状態でクライアントを多重化してsendしてもシングルスレッドで処理されてしまいます。TSimpleServerで当方の環境で多重化したクライアントで使用したらサーバがアボートしました。
そこで、今回はマルチスレッドのサーバを作成することにします。したがってTSimpleServerではなく、TThreadPoolServerを用います。そのほかにもサーバ用ライブラリはいくつかありますが時間の関係もあり全ては未調査なので、このエントリーでは他のライブラリに関しては割愛させていただきます。以下に今回のベンチで使用したTThreadPoolServerを使ったサーバの実装を示します。
#include "TinyCalc.h"
#include <protocol/TBinaryProtocol.h>
#include <server/TThreadPoolServer.h>
#include <concurrency/ThreadManager.h>
#include <concurrency/PosixThreadFactory.h>
#include <transport/TServerSocket.h>
#include <transport/TBufferTransports.h>
using namespace apache::thrift;
using namespace apache::thrift::protocol;
using namespace apache::thrift::server;
using namespace apache::thrift::transport;
using namespace apache::thrift::concurrency;
using boost::shared_ptr;
class TinyCalcHandler : virtual public TinyCalcIf {
public:
TinyCalcHandler() {
// Your initialization goes here
}
double sum(const double param1, const double param2) {
return param1 + param2;
}
double subtract(const double param1, const double param2) {
return param1 - param2;
}
};
int main(int argc, char **argv) {
int port = 9090;
int workerNum = 50;
shared_ptr<TinyCalcHandler> handler(new TinyCalcHandler());
shared_ptr<TProcessor> processor(new TinyCalcProcessor(handler));
shared_ptr<TServerTransport> serverTransport(new TServerSocket(port));
shared_ptr<TTransportFactory> transportFactory(new TBufferedTransportFactory());
shared_ptr<TProtocolFactory> protocolFactory(new TBinaryProtocolFactory());
shared_ptr<ThreadManager> threadManager = ThreadManager::newSimpleThreadManager(workerNum);
shared_ptr<PosixThreadFactory> threadFactory =
shared_ptr<PosixThreadFactory>(new PosixThreadFactory());
threadManager->threadFactory(threadFactory);
threadManager->start();
TThreadPoolServer server(processor, serverTransport, transportFactory,
protocolFactory, threadManager);
server.serve();
return 0;
}
TSimpleServerとの大きな違いはスレッドのマネージャとしてThreadManagerを使うことです。上記の例のように使い方は簡単です。ThreadManager::newSimpleThreadManager(workerNum)この関数では、ThreadManagerを生成するときにworkerの数を指定します。今回は50にしましたが環境に合わせて引数を与えてください。
今回ベンチに使用したソースをUPしておきましたので、ご興味がある方は参照してみてください。コンパイルの仕方などは同封されているREADMEに記載しておきました。
TinyCalc.tar.gz
ベンチ方法
今回のベンチテストではサーバー1台、クライアント8台の構成で行いました。サーバ、クライアントすべて以下のスペックです。
cpu: AMD Opteron 2.4GHz * 4core
memory: 4GB
サーバについて
サーバのプログラムは上記のTinyCalc_server.cppを用い、ThreadManagerの引数に与えるworkerの数は50で行いました。
クライアントについて
クライアントはperlクライアントを用い、実装を以下に示します(TinyCalc.tar.gzに入っています)。localhostの部分は環境にあわせて修正してください。
1クライアントに5プロセスずつ同時に走らせ、クライアントサーバはすべてで8台あるので合計40プロセスが同時に1台のThriftサーバに問い合わせます。
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/gen-perl/";
use lib './gen-perl';
use Thrift;
use Thrift::BinaryProtocol;
use Thrift::BufferedTransport;
use Thrift::Socket;
use TinyCalc;
use constant TEST_NUM => 100000;
my $transport = Thrift::Socket->new('localhost', 9090);
my $client = TinyCalcClient->new( Thrift::BinaryProtocol->new(Thrift::BufferedTransport->new($transport)) );
$transport->open();
for(my $i = 0; $i < TEST_NUM; $i++){
my $arg1 = int(rand(10000));
my $arg2 = int(rand(10000));
my $sum = $client->sum($arg1, $arg2);
my $subst = $client->subtract($arg1, $arg2);
print "$sum\t$subst\n";
}
$transport->close();
結果
上記の構成で5回計測したところ平均28649QPSとなりました。処理は簡単であるためサーバ側のCPUはまだまだ余裕だったので,クライアントが増えればまだあげられると思います。でも、そろそろネットワークがボトルネックになるような気もしました。
考察
僕が想定しているThriftのユースケースでは、主に分類器やクラスタリングなどに使おうと思っています。分類器などの場合は、分類器自体の計算量が高いのでボトルネックは分類器となる可能性が高いと思います。今回Thriftで簡単な処理をさせて約3万QPSが出ることが確認できたので、分類器などを使う場合、Thriftは十分なスピードかな、と思いました。分類器でThriftを使う場合は転送させるデータ量も意識しないといけないので、圧縮して送ったりしないといけないかな、とか考えたりしています。
まだThriftのチェックできていないライブラリも多くあるので、今後もよさそうな情報があったらエントリーを書きたいと思います。情報提供していただいた皆様ありがとうございましたー。当方、ネットワークの知識はあまりないのでとても勉強になりました。多謝多謝。