ISUCON 11 予選に参加した

ISUCON 11 の予選にいつものチーム「ワイハリマ」で参加していた。利用していたリポジトリは https://github.com/yuta1024/isucon11 で、予選終了後に public になっている。最終スコアは 26656、走らせたベンチマークの中で最良のものは 29189。記事執筆時点では最終結果は発表されていないが、再起動後に機能を維持すること自体は確認している。(追記:ベンチマーク周りの事情で追試の際に再計測が行われ、最終スコアは 27163 だった。)

COVID-19 による緊急事態宣言の最中であり、いくらワクチン接種済みとはいってもさすがにチームメンバーも物理的には集合せず、各自自宅からオンラインで参加。

時間としては 10:00-18:00 の8時間の予定で、定刻通り10時開始、17:20頃にポータルに問題があってベンチマークが行えなくなり18:10頃に復旧、競技は18:45まで延長となった。

いつものように言語を PHP に切り替え、ベンチマークを流して kataribe で様子を見たり実装を見たりしながら改善方針を検討した。デフォルトの Go の初期実装で走らせると 1656 だったのに対し PHP に切り替えた直後は 980、初期実装の段階では Go に負けている。PHP は(ISUCON ではいつものことのようだが) Slim が採用されており、しかし ./app/routes.php にほぼ全てが書かれている形のようだ。そして、とにかく isucondition テーブルへの書き込みに該当するリクエストの件数が多いようだ(ただし初期実装では9割のリクエストが PHP 側で無視されている。書き込んだ変更が反映されて初めて点数ではあるものの書き込みが無視されること自体のペナルティはなし)。

isucondition のテーブルで3つの値の true, false をわざわざ CSV 形式のテキストで保存しているのを発見し、 TINYINT 3 つのカラムに切り替える改修を行った。ただ、これは明確に効いたという感じではないようだ。今にして思えば結局このカラムは3カラムまとめて取得してくるものでデータベース側でどうこうするものではないので、実運用上するならともかく、速度だけ気にするなら特に影響が大きいというものではなかったのだと思う。

assets の静的ファイルがなぜか PHP の file_get_contents で実装されているという問題があり、これはメンバーが nginx で返すように修正していた。

ここまで一台のサーバーで三人が作業していたが、3 VM 同じものが動いているので、ここで3人がそれぞれ1台の VM を使うようになった。

とにかく POST /api/condition/<uuid> へのアクセスが非常に多く、無視している9割も一度 PHP で処理しているのはシンプルに問題がある。また、少なくとも ISUCON での経験上は Slim のオーバーヘッドは無視できないほど重くなることもある印象だった。ISUCON の PHP 実装は Slim で書かれており、かつほぼすべてを ./app/routes.php に書いていて Slim のメリットを生かせていないことが多く、もともと PHP が初期実装として有利でない現状では初期実装としてもフレームワークなしのべた書きにした方がバランスがとれるのではとは思うものの、Slim のメリットを活かしたコードになっていないなら逆に改修箇所も小さいので、要所を絞って一つ、二つ脱却するだけなら現実的にコンテストの時間内で行える。そのため、残りの1割でも充分リクエスト数が多いこの API は Slim を脱却したいという話になった。90% の割合は PHP 側で何もしないで処理しているはずにも関わらず、1割を超えるリクエストが nginx のログで status 499、つまり実際にはレスポンスすら返却せずタイムアウトしているのも気にはなっていた。

メンバーが nginx 側のロードバランシング機能を用いて一定の割合を nginx 側で所定の形式で返却、残りのみを PHP に回す処理を書いていて(これも冒頭にリンクを張ったリポジトリに上がっている)、これはかなり効いたようだ。さらに初期実装で 90% を無視していた割合を見直して調査した結果、そもそもこの時点では 90% でも捌き切れておらず、さらに高い値にしてある程度捌けるようにした方がスコアが伸びることが判明した。GitHub のこの時のプルリクエストでは 98% を無視する(2% しか php-fpm に流さない)ことになっていたが、この値は定期的に見直しているのでどの時点での値かは正確には思い出せない。

僕は POST /api/condition/<uuid> を Slim から脱却し単一 PHP ファイルで動作するようにする修正を行っていた。これはさらに別 VM へのオフロードもこの時点で目論んでいた。なお、この API はざっと見た限りでは WebUI からは行われずベンチマークからしか修正の正しさを確認できないのがやや面倒ではあったが、ベンチマーク自体はシステムがスムーズに動作していたので大きな問題でもなかった。

また同時に PHP の xdebug モジュールの無効化やデータベースのスロークエリを確認した上でのインデックス追加を行っているメンバーもいた。

この時点で15時過ぎ、10058 のスコアを記録した。

ここから先は3台の VM の役割を検討し始めた。まず、 php-fpm も mysql も nginx も全て CPU をまあまあ使っていたので、 MySQL は別 VM への移行を決定。更に POST /api/condition/<uuid> も別 VM に渡すことになった。ここで3台の VM の役割がわかれるが、リポジトリに置いていたファイルはあくまで全台に撒いても問題ないように書いていた。少なくとも今回は全台共通で撒けるようにする工夫に大きな手間はかかっていなかったと思うし、余計なことを考えないで良い効果の方が大きかったと思う。

多分この頃、時刻で言えば17時頃に、再起動試験も行った。とはいってもデータの置き場所は結局変更していないので、データ保持の確認は行わず機能維持のみを確認した。ミドルウェア周りはいうほどいじっていないしオンメモリ化もしていないので、特に問題なし。心配する要素がないのでデーモンが上がってくることのみのチェック。ただし無効化したつもりの余計なデーモンが起動してくるということはあった。

この後は、いくつかの重い処理をどの VM にやらせるかを移動したり POST /api/condition/<uuid> の無視割合を変更したりしてはベンチマークを走らせるといったことを3人でしていた。またこのころ、 nginx の client_body_buffer_size の調整や、 InnoDB 周りを主とした MySQL 周りのチューニング、 nginx の各種パラメータの修正をしていたメンバーもいたようだ。

なお、認証が必要な API は単に別のサーバーに逃がすだけだと 401 Unauthorized を返して正常に動作しなかった。調べた結果、 Slim 標準のセッション管理機構がサーバーローカルで閉じていて複数サーバーにまたがるとセッション管理が機能しないことが原因のようだった。ルールにはこういうサーバ側で生成する文字列は文字種を変えなければ自由にして良いと書いてあったと記憶していたこと、 Cookie に関するルールは記憶の限りではなかったこと、そもそも言語標準のセッション管理機構を用いるのであれば初期実装が複数言語あるアプリケーションで共通の何かを守らなければ外部連携に関するトラブルが発生するとは考えづらいこともあり、 Cookie に直接ユーザー名を保存して認証はユーザーの申告を全面的に信用するという方針に実装を変更した。これにより任意の API について php-fpm の負荷はどのサーバーにでも逃がすことができるようになった。

この調整を行い、分散具合や負荷のかかり具合としては良い感じになったと感じた。

この後も無視割合を変更する調整を行った。最終的に、有効1:無視5、つまり無視割合 83% 相当にすることを決定、ログなどの性能分析には有益だが直接的には不利にはなっても有利にはなりえないオプションを無効化し、その状態でベンチマークを二回ほど走らせたら一応は他の設定よりは良さそうな値にはなり、残時間もあまりなかったのでここで作業終了。

後から改めてコードを見てみると、 kataribe 報告で合計時間が多いとされていた API のいくつかには、 isu それぞれについて isu_condition テーブルから最新の情報を取得するクエリが実行されており、これが HTTP リクエスト 1 回に対して多数回呼び出されるものがあった。isu それぞれについての最新情報は isu_condition の時に併せて更新すれば isu テーブルに持たせられることから改善の余地があったと考えられ、今回は実装の修正をおろそかにしていたことが悔やまれる。

コンテストとしては、まず問題としてはプログラム自体の改善と3台の VM の活用それぞれがバランス良く織り込まれていたし、改善やその調査は ISUCON 専用の何かというものが多いわけでもなく実用的な技術の延長上にあるものが多かったので、質が高かったと感じる。また、今回は終了直前にポータルが50分ほど落ちた以外には運営側起因のトラブルは(少なくともワイハリマに影響するものは)発生しなかったし、ベンチマークボタンを押したらキュー待ちもほとんどなくほぼ即時にベンチマークが実行された。ベンチマークの結果自体も同じプログラムで実施すれば複数回実施しても大幅な変動が出ないものであった。全体を通して、8時間の競技の枠内で最大限に高速化技術を発揮できるかに集中できるものになっていたと思う。

(追記)メンバーの write up:ISUCON11 Qual writeup – yuta1024’s diary

ISUCON 10 予選に参加した

いつものようにチームワイハリマで ISUCON 10 予選に参加していた。今回はいつもにもまして手も足も出ない状態だった。

今回は競技に関するルールが事前に公開され、当日の競技開始と同時に ISUCON10 予選マニュアルが公開された。

今回は競技開始後もなかなかサーバに入れなかったり、ポータルが終始不安定でベンチマークも運が良い時しか走らせなかったりした。まずはざっくり画面を操作して機能を把握したうえで、ベンチマークが走るようになったら重いクエリがどこにあるかなど、徐々に詰めていった。また、サーバは 3 台与えられた。EPYC 1vCPU, RAM 1GB なのは良いとして、ディスクが 10GB なのが目に付いていた。

まずは目に付いたいくつかの重い検索クエリを軽くしようと、インデックスを色々張ってみたり、 SQL の外で実行しようとしたり、そもそもウェブアプリケーションの外で実行しようとしてみたりしたが、特段速くはならなかった。SQL が詰まっていたので専用サーバにしたことも、効果を発揮しなかった。recommend では条件が 6 つ OR で繋がっているのを、データの入力を工夫して一つにまとめてみたが、これもうまくいかなかった。

検索が重いことが分かっていたので、レプリケーションを行って検索系クエリを逃してみたが、却って遅くなった。3台のマシンすべてを使うことで CPU 使用率が 100% のマシンはなかったが、全体に CPU 負荷があまりかからなかった。レプリケーションを有効にするためにバイナリログを有効にしたが、これが問題になったようだ。後で考えてみれば、ディスク 10GB に目がついていたが、このディスクが低速で(今となってはわからないが、 SSD ではなく本当にディスクだったのだろうか)書き込みもそれなりに詰まっていた可能性がある。

また今回は、いくつかの最適化を試みた時から、互換性を失わないと思われる変更でベンチマークが通らなくなる現象にも苦しんだ。json_encode 周り及び、 PDO から SQL の結果を受け取る時に独自のクラスで受け取るもとの実装について、どこがしかの挙動を変えてしまったようだが、デバッグログの出し方がわからなかったこともありデバッグには終始時間がかかっていた。

もともと PHP 実装を選択した後一度も Go の初期実装スコアを超えていないことに加え、最終的にはベンチマークでの互換性テストすら通らない状態となり、 0 点となった。高速化視点での実力不足とは別にも、色々と反省すべき点を整理したほうが良さそうだ。

ISUCON 9 予選に参加した

全然ダメでしたが。

いつものチーム「ワイハリマ」で参加していました。前回は結構惜しいところまで行っていたので今年こそは予選突破するぞ!と言っていたら、全然手も足も出ず 5110 点という結果に。

当日はいつも通り、前半は yuta1024 と yabuuuuu が事前準備したインフラに適合する作業をしつつ kataribe やスローログの準備をしている間に僕がデータベースのスキーマとか、見てわかりそうな部分を改善……しようとしていたのだが、今回は SQL のテーブルは最初からそんなに悪い構造になっておらず、結局効いたのは blob が入っているテーブルで検索にインデックスが効かないため画像ファイルを DB から追い出したことくらい。二人がインフラを改善してくれていたのも効いていたようだ。

その後、 3 人合流して分析にあたり、 /transactions.json がとにかく遅いので、改善に着手した。SQL のトランザクションを貼りっぱなしで ShipmentService に https 接続していたのが、切り離しに成功して少しだけ上がる。

その後は buy が遅いということになった。buy は商品を買おうとする注文で、「在庫のある状態の商品なら、 PaymentService に決済を要求し、決済にも成功すれば購入できる、そうでなければ DB の何もかもロールバック」という処理になっていた。在庫状況を確認するのが SELECT FOR UPDATE になっており、行ロックで決済完了まで進める、という処理になっており、要は「買えそうなら商品を仮抑えして決済し、カード番号が違うとかで決済が失敗すればキャンセル」というごく普通の処理になっていたのだが、どうやら「人気商品に注文が殺到する」という、これまた現実にありそうな注文をしてくるようだった。結果、同じ商品に対するほぼ同時のリクエストが全部ロックされ、 fpm のセッション数すら溢れて nginx が 502 Bad Gateway を返す、ということになっていた。

buy に対する根本的な解決策が思いつかず、行ロック緩和について話し合ったものの雑な処理ではベンチが通らなかったりするので結局改善には至らず。別途他の二人がインフラ周りをいじって、 buy だけ専用機で fpm セッションを食わせるようにしたのはそこそこ効いたようだ。

今回、問題自体はとても良いものだと感じただけに、特にその設けられた課題に対して直接的な何かを何もできず、ただただ前年の本選問題を見てすらいなかったことなどを反省することしかできなかった。しかし、どうすればよかったのか、結局わかっていない。

(追記)チームのリポジトリ https://github.com/yuta1024/isucon9

ISUCON 8 予選に参加した話

ISUCON 8 の予選にチーム「ワイハリマ」として yuta1024, yabuuuuu とともに参加していた。ソースコードは GitHub で公開: yuta1024/isucon8 yuta1024/isucon8-infra

一瞬だけなぜか 47k というスコアが出るも、最終的に、最後の方は 33k-36k 程度をさまよいつつ最後の提出が 35,379 点。予選通過ラインは 36,471 点だった模様。競技中は予選通過はもっと遠いものと思っていたら、もう一工夫どころか下手したらベンチマークガチャの引き程度で超えていたかもしれない程度まで近づいていたのか…… 思うところは色々あるが来年がんばりたい。

ということで思い出せる範囲で、自分が実施したことをメモ。

競技前準備

当日の集合のことと、 GitHub のプライベートリポジトリを使用することを決定。その後、個人的な用事により旅立ってしまい前日に戻ってきたが、その間に yuta1024 と yabuuuuu はその間にアプリケーションの配布ツールや想定されたミドルウェアの設定、 SSH 公開鍵配布などを準備してくれていたようだ。ISUCON の日程も自分の不在もずいぶん前からわかっていたし、数日前だと全員いたとしても時間が潤沢にとれたわけでもないので、もう少し早めに(例えばディストリビューションがわかった時点で)動くべきだった。

競技開始

当日集合し作業開始。作業時間は当初のアナウンス通り、 10:00-18:00。

まずは問題を把握。題材はイベントの予約サイトで、管理者はイベントの追加や売り上げの集計が行え、ユーザは席の予約(ランクだけ選んであとはランダム)とそのキャンセル、自分の予約状況の確認が行える。テーブル構成は先ほどのリポジトリの isucon8/db/schema.sql にもとのものが上がっているが、概ねの構成は以下の通り。全てのイベントを一つの会場で実施していることになかなか気付かず、理解するのに少し時間がかかった。

  • ユーザのテーブル。ユーザごとに一行で、普通。
  • イベントのテーブル。公開されているかや価格(一番安い C 席の価格。上位席は席のグレードごとに決まった金額(後述)が加算)などが格納されている。普通。
  • 席のテーブル。S席(番号1-50で価格は(C席と比べた差額が)5000円)みたいなのが S, A, B, C の 4 種類、 1000 席あり、これで 1000 行を構成している。それぞれに id とは別に種別ごとの ID もついている(例えば A 席なら id=51 が 1 番、 id=60 が 10 番……)。なお、会場は一つしかないようで、この席とイベントの相関はない。中身は不変。まあなんというか、要するに明らかに無駄が多い。
  • 予約テーブル。予約ごとに1行だが、キャンセルした予約も参照することがあるため論理削除しか行われない。しかも、キャンセルフラグはわかれておらず canceled_at の時刻が NULL かどうかで判定している。席の空き状況もここで管理しているようだ(ただ、 UNIQUE KEY で二重予約が防止されている)。初期データで19万行程度あるが、そのうちキャンセルされていないのは 15k 程度しかないようだ。もちろん構成としては席テーブルよりはわかるがここも色々と問題になりやすそう。
  • 管理者の一覧。あんまり見ていなかったが、結果的にこれをいじるようなリクエストはあまり問題にならなかった。

また、今回は PHP を選択した。その場合、初期実装は Slim フレームワーク利用だった。時間はかかるが脱却すると速くはなる気はしていたのだが、初期ベンチで明らかに SQL が重かったこともあり、最後まで Slim に載せたままだった。初期状態でスコアは 1k 程度。

与えられた VM が 3 台あったが、初期実装は一台で動作するものだった。

初動

アプリケーションのロジックを主担当とすることになり、ミドルウェアの設定は残りの二人に任せてとりあえず VM のうち 1 台を使って調査開始。最初にどうにかすべきと判断したのはとりあえず席の一覧(1000件)取得後にその一件一件に対して予約状況を確認する SQL 文を発行していたこと(修正途中、別方面で分析を進めていた二人からもここがやばいと報告あり)。席テーブルの内容をハードコードし、ざっくり書き換えたのと、他の二人のミドルウェア改善と合わせてこの時点で 10k 突破(ただ、ベンチごとの揺らぎが激しく、同じコードで 5k を下回ることもあった)

これでも mysql の CPU 使用率がほとんどなので、これはデータベースチューニング回になると判断。

中盤

とりあえず見るまでもなく修正する点をつぶしている間にミドルウェアの整備が進んで kataribe や MySQL のスローログが使えるようになった。/admin/ や / が重いとわかるが、純粋にデータ量が多いため他と同じ処理時間になるわけもなく、他のボトルネックの検討も含めて相談。予約済みのもののみに興味があるクエリでは明らかに無駄で、特にキャンセル済み判定にインデックスが使えていないという yabuuuuu の指摘もあり、キャンセル判定や最終更新時刻など、テーブルにカラムをいくつか追加。変更点は ALTER やサブクエリを使えば SQL 文で完結しそうだったので /initialize の init-db.sh の修正を行い、保存された sqldump には手を付けなかった(その方が後々の変更も楽だし)。追加で必要となったインデックスも追加。

なお、空席選択のコードは初期実装では「空席の中からランダムに選ぶ」になっていたが、ランダムで通るならルール上はどのような規則で選んでも構わないと判断して、手を加える必要があった際にデバッグのしやすい「条件を満たす空席のうち id が最も若いものを選ぶ」コードを書いたところ、「ランダムではない」ことがシステムにバレて失敗扱いになったのはおもしろかった。検出のロジックが少しだけ気になった。

またついでに、潰せそうな SQL 文(SHA256 のためだけにクエリ発行しているクエリなどもあった)も潰してみたが、こちらは効果がほとんどなかった。

この間に他の二人が、ミドルウェア周りの整備および関連コードの修正を進めて DB×1, FE×2 構成に変更になった。MySQL が入っているマシンは MySQL が本気を出せるようになり、この段階で 30k 突破。一回だけなぜか 47,473 点をマークしたが、ベンチを走らせた本人曰く「修正で想定以上に改善したと思ったら、修正が反映されていなかったのに一回だけスコアが上がった」とのことで、理由は謎に包まれている。

また、この時点で必然的に環境が共有になったため、誰かがデプロイをしたくなっても他の人の作業を待つ必要があり、複数台にデプロイする手間も相まってデプロイ頻度が低下してきた。

終盤

時間があれば実施したいことは山ほどあったが、残り二時間を下回ってはそうも言っていられず、改善できそうなところから修正。複数クエリにまたがるトランザクションの削減などを試みるも、効果は限定的。相変わらずベンチごとにスコアが揺らぐ状況は続いていた。

最後の30分はベンチマークガチャに充てよう、などと話しつつ、終了30分前になって実施した再起動試験で、なんと 502 Bad Gateway になるという事件が発生した。再起動後も正常に稼働できるようになっていないと、レギュレーション上当然ながら失格となってしまうため全員で慌てて対処し、かろうじて残った約5分でベンチを回し始めたが、最後のベンチが0点でなすすべなく☀となることは避けたかったので、 17:59:10 に走行し終えたベンチを最後に終了。その時は予選突破はもっと遠い点数であり最後のベンチの点数こそが自分たちの成績になると思っていたが、結果を見た今ではもう一回回していれば結果は変わったかもしれないという大変微妙なところにいたと言える。

反省会

競技終了後、準備不足であったことを感じながら、移動および夕食のため池袋に向かった。サンシャイン60通り商店街周辺から探索を始めたが、三連休の中日の夜でどの店も長い列ができており、徐々に南下しながら探索を行い、最終的には東通り(JR 池袋駅南口の東南東)に到達した。この間に早くも ISUCON 運営の方々による再起動等の試験・集計が完了して公表され、予選突破ラインとの差がベンチマークガチャ程度の差であったことを知った上で、反省会が始まった。

感想

ウェブアプリケーションとして素直な構成でありながら、最初に一台構成で組まれたコードを複数台に分散する要素が活用されており、奇をてらったり知っているかどうかを競うだけのゲームになったりもしなかった。そのため、チューニングらしくて良いと思った去年にも増して楽しめる競技だった。

そして、そのような素晴らしい競技で今回は惜しいところで本選出場を逃したこと、事前準備を早くにしていれば当日時間短縮できそうなところが多くあったところなど、悔しい点もあった。前回の ISUCON 予選は複数台サーバの時計のずれなど、重要な点に気付く必要があり、それには時間があっても気付けなかっただろうと感じたが、今回は時間があればできたと思うことは多くあった。もちろん、 ISUCON の競技としては事前準備が効きすぎてしまうのはよくないだろうと思うし、その想定で情報を掲出している点もあるとは思うが、それでも8時間という短い時間ですべてを出し切るには特にデプロイ周り、時間の使い方や締切、(ISUCON 7 予選で複数 VM となる前例があった今)複数 VM を複数人で活用する方法など、事前準備が避けられない点は準備しておくべきだった。

ISUCON 7 予選参加

ISUCON 7 予選二日目にチーム「ワイハリマ」(yuta1024, nhirokinet, tyabuki)で参加しました。76,904 点でした。

今回の題材は「isutaba」というチャットツールで、

  • ログインすると部屋がたくさんあるので、それぞれの部屋でチャットができる(部屋の権限などはない)
  • 部屋の発言およびほかの部屋の未読バッヂは、ストリーム風にリアルタイムに表示される。それを実現するために、各ブラウザが js で無限に新着を確認する「/fetch」 JSON APIをたたき続ける。
  • ブラウザは /fetch が返ってきてそのレスポンスを処理したら、また次の /fetch をたたく。サーバ側の /fetch 側はデフォルトで 1 秒待機(sleep 1)だが、こちらは変更可能。
  • デフォルトで、 web app 2 台、 db (MySQL) 1 台の計 3 台構成。ただし、構成は自由に変更可能で、 FE サーバの集合は自由に選択可能。
  • デフォルトではユーザアイコンも含めて自由に変更可能で、全部 db に載っている。ユーザアイコンの URL は、デフォルトでは画像の sha + 拡張子。画像への権限管理なし。

という内容でした。

リポジトリ

isubata リポジトリ by チーム「ワイハリマ」

準備内容

最低限、よくある MySQL や nginx の設定、kataribe、公開鍵一覧と配布スクリプトを準備。プライベートリポジトリに関しては、性能に余裕のあるマシンで GitLab を用意し、全員のアカウントを作り、外からも使用可能なようにしておいた。

当日

思い出せる範囲で。個人で書いているので、他の二人の実施内容に関しては違っている可能性がある。

  • 開始が10時から12時に変更だったのでそれに合わせて集合したが、最終的には13時になったので最初の進捗
  • まず、サーバに全員分の鍵や、 MySQL には InnoDB の書き出し間隔の遅延、 MySQL インデックスの追加などを実施。
  • 一旦一台構成に移行し、 icons を MySQL ではなく静的ファイルとして扱うよう変更。(初期段階としては)ある程度伸びる。
  • サーバのミドルウェアを一部は新しいものに更新しつつ、設定をざっくりチューニング。また、設定ファイルを扱いやすくするなどを実施。キャッシュ回りもいじっていたようだ。ここは担当していないので詳細はよくわからないがここで点数が大幅に伸びていた模様。
  • リクエストが多い /fetch, /message について、 Slim 脱却を試みる。テンプレートを使っていなかったのでそれ自体の修正量は少なめ。
    • ここで、 /fetch での sleep(1) が話題に上る。単につぶすと性能が下がる。 /fetch 自体はスコアに勘定されないルールになっており、単に排除するといたずらに負荷をかけることになる。
  • 40k を超えたあたりで、 php-fpm のワーカ数を調整しようとして、 sleep(1) が話題に上る。これが php のプロセス数を食っているようだ。とりあえず増加。
  • 静的ファイルの icons を相手のサーバにも送り付ける口を作り web app を二台に戻すが、性能が上がらない。
  • とにかく /fetch の sleep の問題が大きいということで、 /fetch だけは専用の php-fpm プールに逃がすように変更してスコアが上昇。
  • 同時に、ベンチマーカーのエラーメッセージや kataribe の結果などから、 icons が異常に遅延(nginx 視点でテイルレイテンシ 10 秒)していることに気付く
  • 外向け 100Mbps 、一分間での画像リクエスト約 1.4k ということで、画像ファイル平均 500kB 程度とすると帯域をほぼ使い切っている計算。
  • メモリを使い切ってキャッシュが減っているのでは?という疑惑はあった
  • ベンチマークを何度か走らせると、 +/- 15% 程度は平気で増減する状態であり、ガチャの影響も大きかった
  • icons が大幅遅延するかどうかもガチャ次第だった。
  • web app を二台活用できない理由も、 icons が遅延する(あるいは上位チームがこの何倍ものスコアを叩き出せる)根本原因もわからないが、終盤が近づいたので web app を一台運用することを決定。
  • 再起動試験実施。
  • 再起動後二回目の試験で、運よく過去最高得点が出た。この時終了約15分前だったので、以後外れベンチマークを引かないように全ての作業を終了。

感想

今回は、ウェブアプリケーションとしてはかなり王道なチューニングが主で、それでいて難易度が高く仕上がっていて、良い問題だったと感じました。

まだほかのチームの write up はあまり見られていませんが、それでも特に複数台構成/ローカルファイルシステム同期における /icons の更新日時や、せめてもの二台目の活用方法など、少し読んだだけでも見直すべき部分が色々出てきました。

二日目の通過ラインは 210,464 点で、ここからさらに三倍食えるようにしないといけなかったようです。とはいえ、学びはあったので、今後予選通過を狙えるようにしたいです。

ISUCON 6 予選参加

チーム「にゃーん」で ISUCON 6 予選参加して惨敗してきました。厳しい。というわけで記録です。といっても、結局自分が担当した部分以外はあまり把握していないので、それは各自がブログか何かを書くのを待ちます。

まず、インスタンスを起動して、あまり分ける必要がない二つのアプリケーションが動いていることを確認したので、れにが二つをマージしていました。その間に DB のインデックスで露骨にまずいものがないか見ていましたが、一応張ったもののそれほど大幅ということはありませんでした。

htmlify なる関数があり、これが今回のメインのようでした。記事(初期状態で約七千)のタイトルを本文中から自動抽出してリンクに変換するものですが、全ての記事のタイトルを抽出したうえで正規表現や SHA1 を活用した実装になっていて遅いこと遅いこと。

なお、今回は Ruby だったこともあり初期実装は0点でした。

そのあと、他のチームメンバーは Web 周りのチューニングなどをしてそれなりに点数を伸ばしていたようですがあまり把握しておらず、僕は htmlify をなんとか早くしようとしていました。

他のメンバーと違い、僕は Ruby の知識があまりなかった状態でこれを見たので、 Ruby の置換が同様に書かれたほかの言語と比べても遅いことに絶望して、なんとか早くするアルゴリズムを模索しました。とりあえずなんにしてもその部分を Ruby で書くのは諦めて、 C++ で書いて外部コマンドで呼び出すことにしました。(引数で辞書と入力を渡し、出力は標準出力)

二分探索をしようとしたりさまよっていましたが、最終的にはハッシュマップで実装しました。与えられた文字列に対して短いほうから辞書にある最大の長さまで試す、ただし途中で打ち切るための情報を埋め込む、というものです。ポインタでいろいろ扱える方が楽だったのでポインタをベースとし、何かとコピー回数の増える std::string はなるべく使わないように変更、 C++ の std::map は hashmap ではないことを思い出して std::unordered_map に変えましたが、パフォーマンス計測値、スコアともに改善はしたものの依然遅いままでした。

#include <vector>
#include <string>
#include <string.h>
#include <algorithm>
#include <stdio.h>
#include <unistd.h>
#include <map>
#include <iostream>
#include <unordered_map>

using namespace std;

void urlwrite(const char *str) {
	while(*str!='\0'){
		if ((*str>='a' && *str<='z') || (*str>='A' && *str<='Z') || (*str>='0' && *str<='9') || *str=='.' || *str=='-' || *str == '_' || *str == '~') {
			fwrite(str, 1, 1, stdout);
			++str;
			continue;
		}
		int t = *((unsigned char*)str);
		int b1 = t/16;
		int b2 = t%16;

		char tmp[3];
		tmp[0]='%';
		tmp[1]=(b1<10)?(b1+'0'):(b1+'A'-10);
		tmp[2]=(b2<10)?(b2+'0'):(b2+'A'-10);

		fwrite(tmp,1,3,stdout);
		++str;
	}
}

void htmlwrite(const char *str) {
	while(*str!='\0'){
		switch (*str) {
			case '&':
				fwrite("&amp;", 1, 5,stdout);
				break;
			case '<':
				fwrite("&lt;", 1, 4, stdout);
				break;
			case '>':
				fwrite("&gt;", 1, 4, stdout);
				break;
			case '\'':
				fwrite("&#039;", 1, 6, stdout);
				break;
			case '\"':
				fwrite("&quot;", 1, 6, stdout);
				break;
			case '\n':
				fwrite("<br />\n",1,7,stdout);
				break;
			case '\r':
				fwrite("<br />\r",1,7,stdout);
				break;
			default:
				fwrite(str,1,1,stdout);
		}
		++str;
	}
}


int main (int argc, char *argv[]) {
	unordered_map <string, int> dic;

	setvbuf(stdout, NULL, _IOFBF, 102400);

	size_t longest=0;
	for(int i=2;i<argc; ++i){
		string key=string(argv[i]);
		for(int j=1;j<4;++j){
			string tmp=key.substr(0,j);
			if(!dic.count(tmp)){
				dic[key.substr(0,j)]=1;
			}
		}
		{
			int j=6;
			string tmp=key.substr(0,j);
			if(!dic.count(tmp)){
				dic[key.substr(0,j)]=1;
			}
		}
		dic[key]=2;
		longest=max(longest, key.length());
	}

	while (*argv[1]!='\0') {
		int skiplen=1;
		string title;
		int flag=0;

		for(int i=1;i<=longest;++i){
			char tmp=argv[1][i];
			argv[1][i]='\0';
			string key=string(argv[1]);
			argv[1][i]=tmp;
		
			if(dic.count(key)){
				if(dic[key]==2){
					title=key;
					flag=1;
				} 
			}else{
				if(i<4 || i==6){
					break;
				}
			}
		}

		if(flag){
			fwrite("<a href=\"/keyword/", 1,18,stdout);
			urlwrite(title.c_str());
			fwrite("\">", 1,2,stdout);
			htmlwrite(title.c_str());
			fwrite("</a>", 1,4,stdout);
			skiplen=title.length();
		}else{
			char tmp=argv[1][1];
			argv[1][1]='\0';
			htmlwrite(argv[1]);
			argv[1][1]=tmp;	
		}
		argv[1]+=skiplen;
	}
}

※ちなみに、<a href=”/keyword/~~”> の部分は、 Ruby の見本実装では <a href=”http://192.0.2.1/keyword/~~”> のように URL 変換済み、 PHP の見本実装では <a href=”/keyword/~~”> のように path 部分のみでした。後者で書きましたが、不思議でした。

その後、 Trie 木を実装しましたが、初期化コストが大きく辞書を作るのに 100ms 程度かかってしまいました。外部コマンドなので毎回初期化していたのと、特にバイナリでいい感じに複数のデータを受け渡す実装の時間もなく、トップページを表示するのに 10 回読みだしてしまい、却って遅かったのでこちらは導入に至りませんでした。Trie 木生成後の処理は一桁速くなったように思われる(雑な計測からの体感)ので、 Ruby のモジュールにするか、これ専用のサーバを立てて記事追加・削除・データ変換が毎回行えるようにすればかなり高速化できたものと思われますが、すでに終盤でそこまでする時間がありませんでした。

#include <vector>
#include <string>
#include <string.h>
#include <algorithm>
#include <stdio.h>
#include <unistd.h>
#include <map>
#include <iostream>
#include <unordered_map>

using namespace std;

void urlwrite(const char *str) {
        while(*str!='\0'){
                if ((*str>='a' && *str<='z') || (*str>='A' && *str<='Z') || (*str>='0' && *str<='9') || *str=='.' || *str=='-' || *str == '_' || *str == '~') {
                        fwrite(str, 1, 1, stdout);
                        ++str;
                        continue;
                }
                int t = *((unsigned char*)str);
                int b1 = t/16;
                int b2 = t%16;

                char tmp[3];
                tmp[0]='%';
                tmp[1]=(b1<10)?(b1+'0'):(b1+'A'-10);
                tmp[2]=(b2<10)?(b2+'0'):(b2+'A'-10);

                fwrite(tmp,1,3,stdout);
                ++str;
        }
}

void htmlwrite(const char *str) {
        while(*str!='\0'){
                switch (*str) {
                        case '&':
                                fwrite("&amp;", 1, 5,stdout);
                                break;
                        case '<':
                                fwrite("&lt;", 1, 4, stdout);
                                break;
                        case '>':
                                fwrite("&gt;", 1, 4, stdout);
                                break;
                        case '\'':
                                fwrite("&#039;", 1, 6, stdout);
                                break;
                        case '\"':
                                fwrite("&quot;", 1, 6, stdout);
                                break;
                        case '\n':
                                fwrite("<br />\n",1,7,stdout);
                                break;
                        case '\r':
                                fwrite("<br />\r",1,7,stdout);
                                break;
                        default:
                                fwrite(str,1,1,stdout);
                }
                ++str;
        }
}

class Trie {
        public:
                Trie* children[256];
                uint32_t ex[8];
                int exist=0;

                Trie(){
                        memset(ex,'\0',32);
                }

                void insert(unsigned char *str) {
                        if(*str=='\0') {
                                exist=1;
                                return;
                        }


                        if(!((ex[(*str)/32]>>((*str)%32))&1)){
                                children[*str]=new Trie();
                                ex[(*str)/32]|=1UL<<((*str)%32);
                        }
                        children[*str]->insert(str+1);
                }

                int find(unsigned char *str) {
                        int tmp=-2;
                        if (exist) {
                                tmp=-1;
                        }

                        if((ex[(*str)/32]>>((*str)%32))&1){
                                tmp=max(tmp, children[*str]->find(str+1));
                        }

                        if(tmp==-2)
                                return -2;
                        else
                                return tmp+1;

                }

};

int main (int argc, char *argv[]) {
        unordered_map <string, size_t> dic;
        Trie trie;

        setvbuf(stdout, NULL, _IOFBF, 102400);

        for(int i=2;i<argc; ++i){
                trie.insert((unsigned char*)argv[i]);
        }


        cout<<"<br>clock="<<clock()<<"<br>";

        while (*argv[1]!='\0') {
                int ret = trie.find((unsigned char*)argv[1]);
                int skiplen=1;

                if(ret>0){
                        char tmp=argv[1][ret];
                        argv[1][ret]='\0';
                        fwrite("<a href=\"/keyword/", 1,18,stdout);
                        urlwrite(argv[1]);
                        fwrite("\">", 1,2,stdout);
                        htmlwrite(argv[1]);
                        fwrite("</a>", 1,4,stdout);
                        skiplen=strlen(argv[1]);
                        argv[1][ret]=tmp;
                }else{
                        char tmp=argv[1][1];
                        argv[1][1]='\0';
                        htmlwrite(argv[1]);
                        argv[1][1]=tmp;
                }
                argv[1]+=skiplen;

        }
        cout<<"<br>clock="<<clock()<<"<br>";
}

この辺をバックグラウンドで走らせる準備などが必要なのだと学ぶことができました。