💌

isucandarとISUCON9予選ベンチマーカーについて

2020/10/11に公開

前提

このエントリーは isucon/isucon9-qualify: ISUCON9予選 のベンチマーカー実装者としての観点から isucon/isucandar の解説をします。isucandar自体の解説についてはリポジトリのREADMEにある程度書かれているので、そこを参照してください。

このエントリーはISUCON9予選ベンチマーカーを実装していた時に私自身が何を考えていたのかという思い出話も入ります。ベンチマーカーを実装したことがある人以外にはほとんど益がないエントリーになると思いますが、そういうエントリーなので興味がなければこの先は読まずに閉じることをおすすめします。

また去年私が書いた以下のエントリーを読んでいることが前提になるので、このエントリーを読んでいない人はまずこちらを読んでから読んでください。

またベンチマーカーの実装は以下にあります。多分実装をある程度読んでいないと何のことを言っているのか理解できないと思います。

isucon/isucon9-qualify: ISUCON9予選

ISUCONのベンチマーカーはどういうプログラムなのか

ISUCONでは運営が用意しなければならないシステムは多岐にわたります。その中でベンチマーカーだけは異質であり、歴代の出題者が最も苦労してきたプログラムです。

というのも例えばISUCONの参考実装やポータルなどは一定規模の仕様を持ったWebアプリケーションであり、Web企業内でバックエンドの経験と知識があれば理解もできるし、実装もできます。ポータルはベンチマーカーを実行するためのジョブキューのシステムを持っていますが、Railsにおけるsidekiqなどを挙げるまでもなく、ジョブキューのシステムを持っているWebアプリケーション自体は珍しくありません。色々な要件がありますが、ポータルも一般的なWebアプリケーションのノウハウがほぼそのまま使えるため、ISUCON特有のシステムとは言えません。

しかしベンチマーカーだけは違います。どこまで理解されているか分かりませんが、ベンチマーカーのやっていることを一言で言えば『並行にアプリケーションが死なない程度に大量のリクエストを特定のWebアプリケーションに送り続けて、そのレスポンスが正しいかどうかを並行に検証し続けるプログラム』です。最も必要な知識は並行プログラミングに関するものであり、他にもネットワーク・HTTPに関する知識やシステムプログラミングの知識も求められます。

初期のISUCONの頃は違いましたが、少なくともISUCON4以降のベンチマーカーはGoかJavaでしか作られていません(ISUCON5のみがJavaでそれ以外は全部Go)。この2つの言語は並行プログラミング・ネットワーク・システムプログラミングなどを活用できるライブラリや言語側の仕組みが整っています。今後もこれらの仕組みが整った言語でしか作成はされないと思います。

並行プログラミング・ネットワーク・システムプログラミングなどの知識は多くのWebエンジニアにとって馴染みの薄いものであり、しかもISUCONのベンチマーカーは作成したことがある人も数えるほどしかいません。そのためノウハウがあまりたまっておらず、歴代の出題者が最も苦労してきたプログラムであり、数々のトラブルも起こしてきました。

ISUCON9予選ベンチマーカーはその状況を打破することも目標の一つでした。git logを確認すれば分かりますが、ISUCON9予選ベンチマーカーは非常に小規模なプログラムから始まり、フルスクラッチで実装しています。これは今後のベンチマーカー実装者が参考にできるように、できる限りシンプルでわかりやすいプログラムを提供することを目標にしたことが理由の一つです。

isucandarとISUCON9予選ベンチマーカーについて

isucandarはREADMEにある通り、

isucandarはISUCONなどの負荷試験で使える機能を集めたベンチマーカーフレームワーク

です。

前提としてISUCON9予選ベンチマーカーはフレームワークのような仕組みはありません。使用しているライブラリもエラーメッセージの組み立てのために morikuni/failure を使ったのと、アプリケーションの仕様上QRコードの画像を生成する必要があったため、そのためのライブラリの2つ以外は標準ライブラリしか使用していません。

isucandarはISUCON9予選ベンチマーカーが自前で備えている仕組みをいくつも提供しています。

ISUCON9予選ベンチマーカーは複数のシナリオを並行に実行しつつ、各シナリオの速度を制御してそれぞれのシナリオが暴走しないようにしています。実行時間が決まっているため、各シナリオをすべてcontextを使って任意のタイミングですべてを中断できるようにしています。これらの仕組みはベンチマーカーを暴走させないために非常に重要な仕組みです。ISUCON9予選ベンチマーカーはこれらを何らかの仕組みで保証しているわけではなく、すべてのプログラムを私が書く OR レビューすることによって保証しています。1つでもcontextで終了しないプログラムが残っていると暴走する可能性があります。

このcontextで終了しないプログラムを渡すと暴走する可能性がある問題はisucandarにもある(というかほぼすべてのGoのプログラムにある)のですが、isucandarはworkerというpackageでこの機能を提供しています。提供されている仕組み自体はISUCON9予選ベンチマーカーとほぼ同一ですが、シナリオの一部として実装しているISUCON9予選ベンチマーカーと違い、packageとして簡単に使用できるようにしています。

この辺りのロジックはISUCON9予選ベンチマーカーからかなり引き継いでいます。差については後述していきます。

私がisucandarを作らなかった理由

私はISUCON9予選ベンチマーカーが初めて作ったベンチマーカーではありません。運営としてISUCON6本選の運営を行っていますし、問題として catatsuy/private-isu を一から作った経験があります。

この経験から ISUCON9予選の出題と外部サービス・ベンチマーカーについて に記述した通り

以前の反省から以下の方針を決めていました。

  • ペラ1のファイルから拡張していく
  • フレームワークっぽい仕組みを作らない

ISUCONのベンチマーカーは例外のオンパレードです。フレームワークっぽい仕組みを用意しても無駄になるか、異常に複雑になるかのどちらかです。今回は1から作ることで最初のコストは高かったですが、メンテナンス性の高いベンチマーカーを作ることができたと思います。

と意図してフレームワークっぽい仕組みを作りませんでした。これはISUCONのベンチマーカー作成ノウハウが自分の中にたまっておらず、ISUCONのベンチマーカーとして一般的に求められる仕様について理解していなかったことが理由です。

一般的に求められている仕様の理解が足りてない状況で汎用的な仕組みを下手に提供しようとすると大体失敗します。抽象的な仕組みを提供したいという気持ちはありましたが、自分の知識では作れないと判断し、強い意志で抽象化を行わない方針でISUCON9予選ベンチマーカーは作成しました。あと2,3回、全然違う問題のベンチマーカーを作成すれば共通した汎用的な仕組みが固まり、自分もisucandarのようなライブラリを作ろうとしたかもしれません。

ISUCON9予選ベンチマーカーはcontextで全シナリオを終了できるようにするだけでなく、過去行われていない以下のチャレンジがありました(史上初をかなりやったので、あくまで一例です)。

  • スコアをリクエスト単位ではなく売り上げというシナリオに依存したものにする
  • 人気者出品と呼ばれる並行処理のオンパレードのような処理をする
    • 1人のユーザーが高額の出品をして、その商品を大量のユーザーが購入しようとする
  • ベンチマーカーがクライアントだけでなく、2つの外部サービスを直接持ってサーバーも立てる
    • 「決済サービスAPI」「配送サービスAPI」の2つを持ち、内部的に検証を行う

複数の初のチャレンジをするにはベンチマーカーの拡張性を担保する必要がありました。下手にフレームワークっぽい仕組みを作ると拡張性の足かせになる可能性があり、拡張性を阻害しないライブラリを提供できる自信がありませんでした。そこでやりたいことをできる限りシンプルなコードで実現することを目指して、フルスクラッチで必要な機能のみを開発しました。それによりISUCON9予選ベンチマーカーはある程度形になるまで少し時間がかかりましたが、後半はギリギリまで開発速度を落とさずに開発することができました。時間さえあればシナリオを修正したり足すことは容易だと今でも考えています。

isucandarのようなライブラリをISUCON9予選の時に作らなかったのは今でも成功だと考えています。もし作っていたら途中でライブラリの設計が破綻して、実装予定だった多く機能の実装を断念したでしょう。

isucandarとISUCON9予選ベンチマーカーの差

isucon/isucandar のパッケージをいくつか解説してみます。

ISUCON9予選ベンチマーカーの実装について書いているので、以下のエントリーと実際のベンチマーカーの実装を読んでないと何を書いているか理解できないと思います。

agent

agentパッケージの話を書きます。

isucandar/agent はブラウザに近い(似せた)挙動をすることを目的として作られたパッケージです。
net/http を基礎にしつつ、いくつかの拡張が行われています。

isucandarはGoではよく見るFunctional optionsという方法でoptionsを渡すことで比較的容易に挙動を変えることができます。

Functional options for friendly APIs | Dave Cheney

また304が扱えたり、gzipだけでなくBrotliも扱えたり、HTMLの解析によりリクエストを送る機能などブラウザを意識した実装になっています。

ISUCON9予選ベンチマーカーではsessionというパッケージ上で1つ1つ手作りしていました。この実装は非常に重複が多く、コピペもかなり多いです。これは一見非効率に見えますが、すでに解説した通り、「ISUCONのベンチマーカーは例外のオンパレード」なので一部の複雑な実装によって、全体の実装が複雑になることを避けるためにわざとこのような実装にしました。

ISUCON9予選ベンチマーカーの設計思想の1つで「書きやすさよりも読みやすさを優先する」という方針で作っていました。これにより書くのが少し面倒になりますが、個々の実装は非常にシンプルで読みやすい実装になります。一部の複雑な実装は複雑な関数になり、シンプルな実装はシンプルな関数という対応になります。この方針はギリギリまで開発速度を落とさずに開発することができた理由の一つになったと考えています。

またISUCON9予選ベンチマーカーではブラウザの挙動をほとんど意識していませんでした。これは以前にブラウザの挙動を意識したことにより実装が無駄に複雑化してしまった反省から、必要になるまで実装しないという方針で作った結果です。また帯域の問題にはしないという方針もあったので、静的ファイルへのリクエストは最低限にしていたことも影響しています。

ISUCON9予選ベンチマーカーでは『DNSが登録されてないドメインへHTTPSのリクエストを証明書を検証した上で送る』という特殊な挙動をしていました。そのために以下のような実装をする必要があります。

&http.Client{
	Transport: &http.Transport{
		TLSClientConfig: &tls.Config{
			ServerName: ShareTargetURLs.TargetHost,
		},
	},
}

このオプションを渡す方法はagentパッケージにはありません。ではagentパッケージではISUCON9予選ベンチマーカーは作れなかったのかというと、そういうことはありません。前述したFunctional optionsの機能を使って以下のような関数を用意すれば書き換えることができます。

func WithTLSServerName(host string) agent.AgentOption {
	return func(a *agent.Agent) error {
		a.HttpClient.Transport = &http.Transport{
			TLSClientConfig: &tls.Config{
				ServerName: host,
			},
		}
		return nil
	}
}

このように簡単な変更ならばpackage外の関数からでも挙動を変更できることがFunctional optionsのうれしいところです。Goだとライブラリのコードに直接手を入れないと拡張しにくいことが多いのですが、そういったことがないように作成されているところがうれしいところです。こういったところから本気でフレームワークとして使えるようにするという強い意気込みを感じることができます。

一般的なGoのプログラムではhttp.Clienthttp.Transportを都度生成することは御法度とされますが、ISUCONのベンチマーカーの場合、複数のクライアントが存在することを模倣したいため、都度生成する以外ありえません。ISUCONのHTTPクライアントとしては便利に使えそうです。

ISUCON9予選ベンチマーカーではsessionパッケージ上でJSONデコードや簡単なチェックを行っていました。都度関数を用意すれば、このような実装をすることも容易です。外から実装すれば何でもできてしまうので、isucandar側を拡張する必要性が少ないように実装されています。

failure

isucandar独自のエラーや、それらのコレクションを扱うパッケージです。

エラー収集の仕組みです。自分のエントリーにも書きましたが、ベンチマーカーのエラー処理は他のアプリケーションとは少し違います。抜粋すると以下です。

  • 運営は生のエラーを見たいが、競技者にはこちらで用意したメッセージを見せたい
  • 致命的なエラーなどエラーにも深刻度によっていくつか種類が必要
  • エラーが起こっても他の処理は続行されて、最後にまとめてメッセージを競技者に見せたい

これらを実現するためにISUCON9予選ベンチマーカーではmorikuni/failureを使ってエラーにメッセージを付与し、エラーを集めるためにfailsパッケージを自前で用意しました。

failsパッケージはかなり手抜きの実装になっており、元のエラーは標準出力に出すのみで、付与したメッセージとエラーコードだけを集めていました。またシナリオはcontextを渡してcontextがDoneになったら全部終了するように作られているのに対して、failsはcontextに対応していません。これはシナリオの実行した直後に数を取得するのでcontextでわざわざエラー収集を止める意味が少ないからです。本来はcontextを渡せるべきですがISUCON9予選ベンチマーカーの実装上は必要性が低いという理由で実装をサボったところでした。

isucandar/failureはISUCON9予選ベンチマーカーで手抜きされていた部分もしっかりと実装されています。フレームワーク化するならば当然必要である仕組みが実装されています。私が手抜きした部分もくみ取った上で実装されているところに驚いてしまいました。素晴らしいです。

score

ここはISUCON9予選ベンチマーカーでは存在しませんでした。ほぼすべてのISUCONのベンチマーカー実装ではpackage化していると思いますが、ISUCON9予選ベンチマーカーではスコアの計算方法がリクエストではなく、売り上げに依存していたことと、減点はエラー数から取得するので専用のパッケージに切り出さなくても十分実装ができてしまう規模でした。必要性が出るまでリファクタリングを行わない方針で作った結果、最後までリファクタリングが行われませんでした。

scoreパッケージはタグを付けてスコアを扱う仕組みなっています。この仕組みはシンプルでありがながら強力で「なるほど!」と声を上げてしまいました。しかもここでもcontextを渡して収集を止めることができます。徹底してcontextで処理を止められるようにしているので安心して使用することができます。

ISUCON9予選ベンチマーカーのように売り上げがスコアになるというような特殊な状況でない限り、ほとんどのケースで活用できると思います。非常に便利そうです。

workerとparallel

worker

同じ処理を複数回実行したり、並列数を抑えながら無限に実行したりする処理の制御を提供します。

parallel

同時実行数を制御しつつ、複数のジョブを実行させる処理を提供します。

この2つのpackageは肝と言っていいでしょう。ここまで紹介したpackageは私にも作ろうと思えば作ることはできたと思います。しかしこの2つのパッケージは私が最もパッケージへの切り出し方法が分からなかった部分です。

ISUCON9予選ベンチマーカーでは全体としてどのような実装が必要とされているのかが当初分からなかったため、都度必要な実装をする方針で実装しました。それにより全体としてはシンプルな実装になりましたが、Goの並行プログラミングに関する深い知識がなければシナリオを1つも理解できない構成になっています。これについてはそもそもGoの並行プログラミングに関する深い知識がなければISUCONのベンチマーカーは絶対に理解できないので気にしてなかったのですが、他の人の実装を見る限り、最も理解がされてない部分だと感じています。

workerパッケージは内部でparallelパッケージを使って並行処理を行っています。基本的にはworkerパッケージを使えばよいのですが、特殊な処理を並行に実行したい場合はparallelパッケージをうまく使えばよい構成です。

やっていること自体はISUCON9予選ベンチマーカーの処理と実はほぼ一緒なのですが、明確に違う点が一つあります。

ISUCON9予選ベンチマーカーでは各シナリオの実行時間をtime.Afterを使って制限していました。これは以下の理由によるものです。

  • 単なるfor文にするとエラー時や何らかの穴を突かれて暴走する可能性があるので一定よりも早く動かないようにする
  • 最適化しにくい特定のパスの速度を落として、最適化しやすいパスへのリクエストを増やすチートができないように特定のgoroutineから一定以上のリクエストをしないようにしておく

workerパッケージを使って何も考えずに実装するとこの問題は発生します。例えばworker.NewWorker(f, WithInfinityLoop())で渡したfの実装が暴走した場合、意図せず大量に実行されてしまいます。ではループ回数を制限してworker.NewWorker(f, WithLoopCount(5))とすればいいのかというと、この場合シナリオの実行速度を制限してない場合は早く完了してしまい、1分あるはずなのに最後の方にリクエストが全然来なくなるといった問題が起こりかねません。

ではどうするのか。これは関数側で一定以上の速度で動かないように制限を各自で実装すべきです。当然contextも渡しているのでcontextがDoneになったら終了することも忘れてはいけません。そうすればISUCON9予選ベンチマーカーの挙動と同じ挙動をさせられるはずです。

parallelパッケージを使用すれば人気者出品のような特殊な要件にも答えられると思います。シンプルな実装なので渡す関数をしっかり作り込む必要がありますが、だからこそ適用範囲が広いパッケージでしょう。

しかしこのパッケージを使ってISUCONのベンチマーカーを作るなら、このパッケージの実装をしっかり読んだ上で使う必要があります。このパッケージの並行処理を理解できない人がこのパッケージを使用してISUCONのベンチマーカーを作るのは不可能だと思います。

しかしこのような抽象化によってISUCON9予選ベンチマーカーの並行処理はpackageに切り出せるのかと驚きました。私の頭の中ではどこまでが抽象化すべきで、どこまでがシナリオで実装すべきなのか判断できていませんでした。ISUCON9予選ベンチマーカーの実装をここまできれいに整理してもらえてとてもうれしいです。

今後のISUCONベンチマーカー作者はisucandarを使うべきか

これについての自分の結論は

ISUCONのベンチマーカーをフルスクラッチで作り切れる自信がないなら使った方が良い

です。
今後ISUCONのベンチマーカーをフルスクラッチで実装する人が現れること自体も応援したいですが、自信がないなら間違いなく失敗するのでisucandarを使った方がいいでしょう。ISUCON9予選ベンチマーカーも参考にしてもらえたらうれしいです。

最後に

ISUCON9予選ベンチマーカーを作るときに私が完全に諦めたフレームワーク化に挑戦して、ここまでのものを公開したことは驚異的だと思います。そしてISUCON9予選ベンチマーカーで私がやったことは無駄ではなかったと感じることができました。ISUCON9予選ベンチマーカーへのかなり強いリスペクトを感じます。

私自身のISUCONベンチマーカーへの戦いはISUCON6の年に始まりました。そこからどうすればよかったのか何年も考え続けて、やっとたどり着いたのがISUCON9予選ベンチマーカーの実装でした。作り始めた時は完成させられるのか心配でしたが、問題を1つ1つクリアして、最終的にほぼ当初の想定通りのものを作ることができました。作っているときはこのベンチマーカー実装がISUCONを始め、業界内を前進させられればと思っていました。正直他の人に理解しやすいコードなのかはよく分かっておらず、自分以外に理解できる人がいないのではないかという不安に駆られていたのですが、ISUCON9予選ベンチマーカーの実装を完全に理解した上でさらに先に進める実装を見れて本当に良かったです。

ISUCONの実装によって業界全体を先に進めたい、私の当時のその思いが結実した結果として非常にうれしいです。isucandarを使って社内ISUCONなどもこれから実装されることと思います。まだまだISUCONは業界を先に進めるポテンシャルを持っていると思っています。これからのISUCONも楽しみです。

Discussion