チーム「坂寝」の「寝」担当として ISUCON6 に参加しました。
結果、予選が2 日目の 3 位、本戦が 8 位と、そこそこ健闘できたのではないかと思います。
8位!!!!!!!! #isucon pic.twitter.com/scmyLtG0mw
— neguse (@neguse) 2016年10月22日
予選
予選では自分はアプリコードのチューニングを担当しました。
まず最初にそのまま Go 実装に切り替えてベンチを回してみて、ほぼ 0 点からのスタートということがわかりました。つらい…
とりあえずプロファイルをとろうとして、Go の New Relic を試したりしたのですがうまくいかなくて、あきらめて pprof を使ってプロファイリングしました。
プロファイリングの結果、htmlify の正規表現が圧倒的に重いということがわかりました。 あと、isutar がマイクロサービス的な感じになってて、たぶん無駄なんだろうなあという気がしてました。
まずは正規表現オブジェクトをコンパイルした結果をキャッシュして使いまわすような実装にしてみて、8000 点ぐらいとたしかに速くはなったのですがまだまだという感じでした。Go の正規表現は遅いという話もあって、このアプローチだと足りなそうでした。pcre 版の正規表現ライブラリを入れてみるという手もあったのですが、ちょっと簡単には入れられなさそうでした。
次に、どうせ正規表現でやっていることは単なる置き換えなのでstrings.Replaceにできないかなと思って置き換えてみました。すると一気に 60000 点ほどになってめっちゃ速くなったものの、だいぶエラーがでるようになってしまいました。 これはおそらく今回の問題だと単語の最長マッチをする必要があるものの、単語ごとに Replace をしていく方式だと最長マッチにはならないのでエラーとなる、というのが原因だったと思います。あるいは何かしらバグ(更新処理でキャッシュ無効化してないとか)が入ってしまった可能性もあります。
そこで、もうちょっといい Replace 関数はないかとおもって Go のドキュメントを眺めていたところstrings.Replacerを見つけました。これだと単語列をいっきに置換できるので、入力文字列さえ長さ順にソートしておけば最長マッチに使えそうです。
strings.Replacer を使うようにしたり、isutar 機能を isuda に統合+キャッシュしたり、あと単語リストを作るクエリがSELET *
になってたのをキーワードだけにしたり等して、最終的に 148431 点となりました。ただベンチマーカーを走らせるたびに(スコアに影響しない)エラーがたくさん出ていたので、何かしらバグが残っていた可能性は大いにあります。
本戦
本戦では自分はサーバ構成の検討とか Docker まわりのサポートを担当しました。というよりは後述のようにインフラで手一杯になってしまってアプリチューニングまでたどり着けませんでした。
まず最初に Azure のコア数制限にひっかかってホストの起動に失敗する状態でしたので、運営側の対応中に先に何かできないか調べました。するとDeploy to Azure
のリンクからアクセスできる JSON に、デプロイに必要な Ansible のファイルへのリンク、また Ansible のファイルからアプリのソースコードがアクセスできるようになっているのに気づきました。このおかげでちょっとだけ早くソースが読めました。
運営側の対応が終わって、デプロイが完了して、top の結果を見たところ node のプロセスがネックになってそうな感じでした。 そこで以下のように各ホストにプロセスを移動しました。
- isu01
- node -> (一部 go にプロキシ)
- isu02
- go
- isu03
- mysql
ここの変更で問題が出てちょっと手間取りました。1 つめは Azure のホスト名での名前解決がコンテナ外からは使えるもののコンテナ内からは使えないというものです。しかたなくdocker-compose.yml の extra_hostsにホストの IP アドレスを書いて無理やり解決しました。 2 つ目の問題は go からの MySQL へのコネクション数が足りなくなるというものです。これは MySQL の設定を変えればよいのですが、Docker コンテナだとコンフィグファイルを書いて volumes に指定する等設定方法に違いがあってちょっと手間取りました。
このようにしたところ、isu01 の node が CPU 使用率 100%に張り付く感じで、2 コアだと 200%まで行くはずで、おそらくシングルスレッドしか使えてないのだろうなあと思いました。
node の処理は React のサーバサイドレンダリングや静的ファイルの配信、go の api サーバのプロキシという感じで、静的ファイルの配信や api サーバプロキシは nginx とかに任せた方がよさそうな気がしました。 そこで以下のようにサーバ構成を変更しました。
- isu01
- nginx -> (node, go にプロキシ)
- node
- isu02
- go
- isu03
- mysql
このように変えてもまだ node の CPU 使用率が高かったので、最終的に以下のような構成になりました。
- isu01
- nginx -> (node, go にプロキシ)
- node -> (go にプロキシ)
- isu02
- go
- isu03
- mysql
- isu04
- node -> (go にプロキシ)
- isu05
- node -> (go にプロキシ)
このあたりで 16 時ぐらいになってました。そろそろアプリのチューニングをしようと思ったのですが、プロファイリングに失敗してうまくいきませんでした。
まず pprof やgo-torchを使おうとしたのですが、使ってたフレームワークの goji だとimport _ "net/http/pprof"
するだけだと/debug
のパスでのハンドラが動かずプロファイリングが行なえませんでした。これはnet/http/pprof
パッケージが公開しているハンドラ関数を直接mux.HandleFunc
に指定することでなんとか動いたようです。
次に、コンテナの外から go-torch を実行したところ、エラーの内容は覚えていないのですがうまくいかずでした。おそらく pprof を使う上ではプロファイリング対象の実行ファイルのデバッグ情報が必要なものの、コンテナの外からでは実行ファイルにアクセスできないのでだめなんじゃないかという気がします。
プロファイリングが手詰まりになってしまったのでなんとなく重そうかつ簡単にキャッシュ化できそうなところをやろうとしたのですが、逆にスコア下がってしまったりしてうまくいかずでした。 結局アプリ側のチューニングはほとんど手付かずで、スコアに影響したかはわからないです。
そんなこんなで、最終的なスコアは 13979 点となりました。
感想
予選については、割と満足に取り組めたかと思います。これはプロファイリングでボトルネック探し → チューニング・実装 → 再度ベンチマークをとって確認というループを効率よく回すことができたためと思います。 また Go のアプリはチューニングしやすいという点がよかったです。これはもう少し詳しく言うと、アプリのコードに手を入れたときに何か間違いがあればコンパイルエラーという形でわかるので間違いが起きにくいという点、プロセスのメモリにキャッシュを持つということがやりやすい(他の言語処理系・サーバだとプロセスが複数立ってしまってプロセス間ではキャッシュを共有しにくいが、Go だと 1 プロセス内でマルチコアスケールする方式なのでメモリを共有することができてパフォーマンス上有利)という点があるのではないかと思っています。今回の予選問題のように正規表現ライブラリの性能が低いというデメリットもありますが…
本戦については、Docker をやめるという選択肢を早いうちにとれてればまた違った結果が出てたかもと思っています。Docker 由来の問題がいくつか出ていましたし、パフォーマンス的にもホストに直接デプロイするのと比べてオーバーヘッドがあったのではないかと思います。 また問題を見ながら「これ、もうちょっと時間あったらアプリ側のチューニングいろいろしたいなあ」と思っていました。そのうち問題が公開されたらやってみたいです。
全体を通して、今回そこそこ健闘できたのはチーム内コミュニケーションがスムーズにいった点があると思っています。前回 ISUCON5 にも出ていたのですが、各自それぞれの家から Slack を通じてコミュニケーションしながらオンラインで参加するという形式でやっていて、状況の把握がしづらかったというのがあります。今回は予選では家に集まって、本戦では会場に集まってやれたので、状況の把握がしやすかったです。 チームメイトの方には感謝しかないです。
サーバのパフォーマンスチューニングは仕事でも 2 度ほどやったことがあるのですが、自分がやったことに対してスコアやレスポンスタイムという形で改善の結果が目で見てわかるという点が楽しいと思っていますし、プロファイリングの仕方や改善の仕方など、たくさんの知識や実装スキルが有効に機能する貴重な場だと思います。ISUCON は楽しいですし、ぜひ来年もあれば参加したいです。