最速TCPサーバーの条件 〜逆襲の Erlang と Haskell の挑戦〜
釣った反響に応えて echo サーバーを改良していて PyCon JP の発表資料作成が進みません。 自業自得です。 methane です。
逆襲の Erlang
Erlangとは何だったのか でのベンチマーク結果では Erlang のスコアが奮わなかったのですが、 github で 性能改善する pull request をいただきました。 性能が悪かった原因ですが、実は backlog がデフォルトだと 5 で、ベンチマーク開始時の 大量の接続要求を捌ききれていないという状況でした。
高負荷サイトのボトルネックを見つけるには で紹介されている事例と同じ現象ですが、こちらのほうが backlog が小さく、 しかもベンチマーク用クライアントはほぼ同時に大量に接続をしてくるという条件で よりシビアに現象が発生してしまいました。
この問題が修正された Erlang は、 Go を超えて一気にランキング上位に踊り出ました。
Haskell の挑戦
@kazu_yamamotoさんから、 Haskell 版も 同じ環境でベンチマークして欲しいという reply があり、弊社 takada-at が昔作成した echo サーバーを @kazu_yamamoto さんからのアドバイスを受けながら改良して、 GHC 7.0.3 で計測しました。
GHCはGoなどと同じく(コルーチンではない)ユーザーランドスレッドを使って動いているのですが、 Go も Erlang も PyPy + Tornado も抜かして一気に C++ 版の次まで踊り出ました。 (@kazu_yamamoto さんの環境では C++(epoll) 版よりも良いスコアが出ているらしいです。 また、Haskell版は1コアしかCPUを使えていないので、forkを併用すればもっとスコアを伸ばす 余地があります)
関数型言語というと、なんとなくIOが苦手そうなイメージがあったのですが、 このベンチマークはそのイメージを完全にぶち壊してくれました。 さらに、ビルドしたバイナリは VM や外部の特別なランタイムに依存しておらず、 Haskellをインストールしていないサーバーにコピーしてもそのまま動かすことができるのも 個人的に高得点です。
ベンチマーク結果
今回は、 Erlang を修正したのと Haskell を追加した以外に、クライアントの設定を変えて クライアント側の負荷を下げることでより高い負荷を計測できるようにしました。 さらに実験環境の限界性能を試すべく、 C++(epoll) 版を fork したサーバーを用意しました。
試験に使用したスクリプトと試験結果は github上に 公開していますので、そちらをご覧ください。
Google Docs Spreadsheet
最速の条件
前回はthread版とepoll版ではほとんど差が無かったのですが、クライアントの設定変更を行い より高い負荷をかけたときに thread 版の方が一歩リードしました。これは、 epoll 版がデュアルコア CPUのうち1コアしか使っていないためです。
しかし、CPU負荷をモニタリングしていると、thread版はほんの少し速いだけなのにCPUを200% 使いきっており、CPU負荷のうちでも sys が多い状況になっていました。これは、ネイティブスレッドの コンテキストスイッチの負荷だと思います。
なので、最速TCPサーバーの条件とは、基本的にネイティブスレッドではなく軽量なユーザーランド スレッドかイベント駆動方式で接続の多重化を行いつつ、なおかつ複数コアを利用するために コア数程度のネイティブスレッドかプロセスを利用するという物になると思います。
この条件を検証するべく作成した epoll + fork 版サーバーは、予想通り thread 版を大きく 突き放すスコアをたたき出し、しかもまだCPU使用率には余力が残っていました。CPUを使い切れて いないのは、多分実験機のNICの Broadcom BCM5754 が複数コアに割り込みを分散する機能を もっていないためで、NICを増やしたり割り込み分散機能のあるNICを使えばもっと 差が広がると思います。
追記: 本当は速い gevent
gevent の作者から 、 Tornado のコードをそのまま gevent に移植したら Tornado より速くなったよというmerge request があったので、 ベンチマーク結果を更新しました。
gevent は Python の socket をラップして、 recv や send するときに自動的に greenlet というコルーチンを切り替えるクラスを提供しているのですが、このクラスが Python で実装 されているために、通信のたびに Python の関数呼び出しが数回発生していました。
先日のベンチマーク結果では17k req/sec, gevent の開発版を使ったら 19k req/sec なのですが、これは1リクエストあたり 50マイクロ秒程度で処理しているということであり、 Pythonの関数呼び出し数回によるオーバーヘッドが顕著に現れてしまいます。
ラップ版ソケットを使わず直接 io loop を使った Tornado のようなイベント駆動にしたら、 Python上の関数呼び出しが減って、 60k req/sec を処理できるようになりました。これは Go よりも高いスコアです。
とはいえ、 greenlet を使わないと gevent の魅力は半減しますし、 Python のコードを数回実行しただけで大幅に req/sec が下がるということはアプリケーションを 乗せるとすぐにIO部分がボトルネックではなくなるので、 gevent の greenlet を 使った多重化も通常の Python アプリケーションでは十分高速といえます。 現実的にはパフォーマンスのために greenlet を避ける必要に迫られることは無いでしょう