ISHOCON2というISUCONの個人大会で惨敗してきました【優勝スコアと同等の参考実装付き】

昨日(2018年8月25日)、ISHOCON2というISUCONの個人競技バージョンの大会に参加してきたのでレポートです。スコアが思うように伸びなかった方はぜひ読んでみてください。

ISHOCONとは

github.com

f:id:serinuntius:20180826113155p:plain

ISHOCON2

iikanjina showwin contest 2nd (like ISUCON)

ISHOCONとは Iikanjina SHOwwin CONtest の略で、ISUCONと同じように与えられたアプリケーションの高速化を競うコンテスト(?)です。

らしいです。

ISHOCONの特徴

ISHOCONの特徴は、個人的に3つあると思います。

問題がコンテスト開催前からオープンであること

これ、ISUCONでは絶対ありえないと思うのですが、開催する前からオープンです

なので、自分はgraqtのテスト、またISUCONの練習として、1週間ぐらい前に取り組みました!

おかげで、モチベがあがってgraqtの実装がだいぶ進みました!

個人参加であること

ISUCONはチームで参加を前提としていますが*1、ISHOCONはむしろ個人同士のコンテストです。

なので、どれだけ幅広い知識を持っているかが求められていると思います。

僕は、アプリやDB周りはなんとか出来ますが、インフラ系は経験が浅く苦手意識があります・・・。

ベンチマーカーもオープンであること

これもISUCONでは絶対ありえないのですが、なんとベンチマーカーまでオープンになっています。

ベンチマーカーの気持ちがわかれば、スコアが伸びるかも??

Go実装を選択してやったこと

本当に勘違いして欲しくないのですが、事前回答時にmysqlである程度高速化してこれ以上速くならないな〜と思ったので最初からRedisに載せるという攻略方針でやっています。

最初から全部cacheさせる、Redisに載せるは本番のISUCONでは通用しないと思います。

この記事を読んでくださった学生さんや、ISUCON初学者の方にそこは勘違いして欲しくないです。*2

事前回答時のmysqlを一部捨てて、redisに載せるぞというp-rを一応貼っておきます。

Feature/cache by serinuntius · Pull Request #4 · serinuntius/ISHOCON2 · GitHub

CSSをNginxで返す

Goで静的ファイルを返してたので、Nginxで返すようにしました。 プロファイリングツールの導入 & css => nginx by serinuntius · Pull Request #2 · serinuntius/ishocon2-2 · GitHub

プロファイリングツール入れる

graqtをサクッと入れて、見てみるとこんな感じでした。

https://user-images.githubusercontent.com/10571219/44613717-e3f3f200-a852-11e8-9379-5b1da0f49d17.png

普通のISUCONより、デフォルト状態で圧倒的に速いんですよね〜w Maxで0.43ってw

「別にチューニングするほど遅くないやん」 って内心思っちゃいました。

スコア「10:36:49 score: 6460, success: 5380, failure: 0」

templateを外に出す

GolangのWAFとしてginが使われているのですが*3、それのwarningが気になったので修正しました。

詳しくは覚えてないですが、「Handlerの中でSetHTMLTemplateするのマジやめろ」 みたいな内容だったと思います。

r.SetHTMLTemplate(template.Must(template.ParseFiles(layout, "templates/vote.tmpl")))

templateを外に出す & sessionなくす by serinuntius · Pull Request #3 · serinuntius/ishocon2-2 · GitHub

なので、mainの中で登録してあげました。

スコア「10:59:06 score: 6604, success: 5452, failure: 0」

mysqlとnginxのコンフィグをgit管理するように

適当に、いい感じにしました。(したつもり)

ガバガバチューニングなので、 innodb_log_file_size = 1G のせいでバカでかいログファイルが2つも出来て、あとでディスク容量で苦しみます。

スコア「11:09:19 score: 6714, success: 6082, failure: 0」

GETのレスポンスは全部cacheする

アクセスログ見てるとベンチマーカーがアクセスするパターンがわかるのですが、 ざっくりいうと大量の POST /vote が来て、その後に各GETが来るという感じになっていて、POSTが来たときにcacheを消すということをしておけばOKだった。

なので実質、「GETのロジックとかどうでもよくてcache返すのでそこがある程度重くても特に問題ない」 というのが今回のポイントです。*4

その対応がこちら。 Feature/response cache by serinuntius · Pull Request #5 · serinuntius/ishocon2-2 · GitHub

ここはginのおかげでデフォルトでcache機構があってかなり楽できた。 イマイチdocとか実装みても使い方のイメージが掴めなかったので、いろいろ調べているとherokuの公式記事が出てきた。これが参考になった。

devcenter.heroku.com

この記事は、メモリストアをmemcacheにしているけど、オンメモリ実装が普通にあって、それを利用した。

スコアworkload20で「11:28:44 score: 23474, success: 15962, failure: 0」

POST /vote のレスポンスもcacheする

エラーメッセージがいくつかあるのですが、そこもメッセージごとにcacheしてしまえば、テンプレートレンダリングが各種1回だけで済むのでいい感じです。

正常時のレスポンスもcacheできます。

ここは事前に準備してたときに、どうやったら1度呼び出したテンプレートのレスポンスボディが取得できるかいろいろ調べてたどり着いた答えは、 「gin.ResponseWriter の interfaceを実装した structを作って差し替える案」です。

Goはこういうのがサクッと効率良くできるので良いですね。

具体的にはこんな感じです。

type bodyCacheWriter struct {
    gin.ResponseWriter
        // cache用のbody
    body *bytes.Buffer
}

 func (w bodyCacheWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b)
}

使い方はこんな感じ

bcw := &bodyCacheWriter{
    body:           &bytes.Buffer{},
    ResponseWriter: c.Writer,
}

// cはgin.Context
// 差し替える
c.Writer = bcw

// あとは普通に返す
c.HTML(http.StatusOK, "vote.tmpl", gin.H{
    "candidates": candidates,
    "message":    "投票に成功しました",
})

// このあとでbcw.bodyをメモリに載せれば OK

実際には、bcw.bodyからRedisに載せる作業がありますが、本質ではないので省略します。後で気づくのですが、別にRedisに載せなくても普通にmapにcacheした方が良いですね。

スコア workload20で「11:54:47 score: 24550, success: 16950, failure: 0」

はい、ここまで破壊的な変更は一切行っておりません。 かなり安全に書き換えるだけで、24550点になります。

投票をRedisに載せる

これはかなり破壊的な変更になります。

今までvoteテーブルに1レコードずつ、mysqlにInsertしていたのですが、それをやめてRedisのsorted setや普通の文字列型とかでいろいろ持つようにします。

ここのロジック本当に汚くて、自分でも嫌気がさすのですが、こうなってしまいました。もっと効率の良い持ち方があると思いますが、1度思いついてしまうと中々その考えが離れず、最後まで変えることが出来ませんでした。

// goroutineで並列にしてるけどあんまり意味ないかも
var eg errgroup.Group

eg.Go(func() error {
    _, err := rc.ZIncrBy(politicalParty, float64(voteCount), keyword).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})

eg.Go(func() error {
    _, err := rc.ZIncrBy(candidateZKey(candidateID), float64(voteCount), keyword).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})

eg.Go(func() error {
    _, err := rc.ZIncrBy(kojinKey(), float64(voteCount), candidateVotedCountKey(candidateID)).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})

eg.Go(func() error {
    _, err := rc.IncrBy(candidateKey(candidateID), int64(voteCount)).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})

eg.Go(func() error {
    _, err := rc.IncrBy(userKey(userID), int64(voteCount)).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})

eg.Go(func() error {
    sex := candidateIdMap[candidateID].Sex
    _, err := rc.IncrBy(sexKey(sex), int64(voteCount)).Result()
    if err != nil {
        return errors.Wrap(err, "")
    }
    return nil
})


if err := eg.Wait(); err != nil {
    return errors.Wrap(err, "Failed to wait")
}

Sorted Set

  • 政党に対する支持者の声ランキング(zincrby)
key: 政党名
member: 支持者の声
increment: 投票数
  • 候補者に対する支持者の声ランキング(zincrby)
key: 候補者のid
member: 支持者の声
increment: 投票数
  • 候補者の投票数ランキング(zincrby)
key: "候補者の投票数ランキング"
member: 候補者のid
increment: 投票数

文字列型

  • 候補者の投票数(incr)
key: 候補者のid
increment: 投票数

(あれ?これ、候補者の投票数ランキング(zincrby)使えば削れそう)

  • ユーザの投票数(incr)
key: ユーザのid
increment: 投票数
  • 性別別投票数
key: 性別("男"、"女")
increment: 投票数

なんか削れそうなやつも発見しましたが、createVoteはこういう感じになりました。こんだけの数incrするの遅いに決まってるけどmysqlにinsertしないってたぶんこういうことなんでしょうね・・・。厳しいw

けど、これのおかげで GET /がシンプル(大嘘)になるんですよね。

1~10位、それと最下位っていうの、SQLだとかなり厳しいですが、Redisのsorted setを使えば2発でサクッと(?)取ってこれますね。

ZREVRANGEBYSCOREとZRANGEBYSCOREを使います。

// 1 ~ 10 の取得
results, err := rc.ZRevRangeWithScores(kojinKey(), 0, 9).Result()
if err != nil {
    log.Println(err)
}

// 最下位
resultsWorst, err := rc.ZRangeWithScores(kojinKey(), 0, 0).Result()
if err != nil {
    log.Println(err)
}
// これで1〜10位と最下位のsliceの出来上がり
results = append(results, resultsWorst...)

Redisのsorted setあんまりちゃんと使ったことがなかったので、コマンド名が呪文みたいだな〜と思っていたのですが、ちゃんと意味を理解すると簡単ですね。

ベースのコマンドがzrange(昇順)で、startとendのmemberを取ってくるというコマンドになっています。それにスコアをつけてほしかったら、withscoresになる。

で逆順だと、zrevrange になる!それだけのシンプルな話でした。

あとは、ゴリゴリRedis実装に差し替えていくだけです。 詳しくはp-r見てください。特に変わったことはしてないつもりです。

Feature/fix vote by serinuntius · Pull Request #7 · serinuntius/ishocon2-2 · GitHub

スコア workload20で「13:05:49 score: 40228, success: 31964, failure: 0 」

このあたりで、完全にnginxがボトルネックに変わります。 topで見ているとNginxが70%ぐらいを占めています。

都度メモリのアロケーションが走ると重そうなので、 POST /vote を中心にsync.Pool計画

nginxでTLSのキャッシュとか入れてるし、静的ファイルもgzipで返してるし、ベンチマーカーはETAGとか、cache-control ヘッダは見てくれないし、nginxの負荷を落とすのは無理ゲーやろと思って、nginxがボトルネックになっているとはわかってはいつつもアプリを直します。

メモリのアロケーションって結構重い処理みたいで、structとか使い回すと良いみたいですね。その使い回しを楽にやってくれるパッケージが syncにあります。

その名も 「sync.Pool」!

こいつを使えばいい感じにオブジェクトをpoolしてくれるのでメモリのアロケーションが減ります。*5

雑に書いても他のLLと比べたらかなり速いのに、もっとちゃんと書いたら更に速くなるGoはやっぱり最高。

使い方はこんな感じです。

type User struct {
    ID       int
    Name     string
    Address  string
    MyNumber string
    Votes    int
}

// Poolを作る
var userPool = sync.Pool{
    New: func() interface{} {
        return &User{}
    },
}

func newUser() *User {
    return userPool.Get().(*User)
}

// 適当ですけどこんな感じの使用感
func hoge() {
  u := newUser()
  defer u.close()
  u.Name = "hoge"
  fmt.Println(u)
}

// なんとなくファイルみたいにcloseにしておいた
// ここでまっさらにしておかないと不都合がある場合はゼロ値で埋めるみたいな処理があったほうが良いかも
func (u *User) close() {
    userPool.Put(u)
}

実際の対応 Feature/user pool by serinuntius · Pull Request #8 · serinuntius/ishocon2-2 · GitHub

スコア workload 30「2018/08/25 06:21:42 {"score": 41714, "success": 32482, "failure": 0}」

ベンチマーカーとの戦い ~リアルISUCON~

このちょっと前ぐらいから、アプリケーションが高速化しすぎて、ベンチマーカーが荒ぶるという事件が起こりだしました。

運営さんのリアルISUCONです。

僕は、事前に問題を解いていたこともあり、ボトルネックがnginxにありこれ以上スコアを上げることは厳しそうなので、運営にコミットしてベンチマーカーを直すという選択肢を選びました💪*6

アプリへの接続のコネクションの使い回しをせずに、ガンガンリクエストしているとエフェメラルポートが枯渇するという問題だと勝手に思っていたのですが、どちらかというとベンチマーカーもmysqlを持っていてそれをベースにリクエストしているみたいなのですが、どうやらそこのmysqlの接続するところで Too many connections が出ているということがわかりました。

なんでだろうと思って、ベンチマーカーのコード読んだら、都度openしてるじゃないですか〜、ヤダ〜w

f:id:serinuntius:20180826153212p:plain

dbのクライアントを使い回すようなp-rを出しました。

https://github.com/showwin/ISHOCON2/pull/25github.com

最新のcontestブランチから切ったもつもりが、古いブランチでコンフリクト解消しようと思ったら変な差分出てしまって、手間取ったので、このp-rはcloseにして、showwinさんに変更だけ入れていただきました。

テンパって手間取っていたのを優しく待ってくれたshowwinさんは優しい方でした。僕のp-rが入らないのも「良いのか?」と聞いてくださいましたし。

僕としては、とりあえず速くベンチ直って走るようになればどうでも良かったので、速い方でお願いしました!

このとき、焦って、MaxIdleConnectionや、MaxOpenConnectionの設定を忘れました・・・😇

で、なんだかんだしてたら、終了です。 最後までベンチマーカーに嫌われ*7、top10入りさえ出来ませんでした。 手元のベンチマーカーインスタンスでベンチ回すとスコアは4万ぐらいは出ていたので、top10には入れるはずだったのですが。

ボトルネックを解消する意義

ISUCONって本当にボトルネックを改善しないとスコアは思うように伸びないようにできていて、ボトルネックを解消するのが大事です。

こんなツイートもしています。

かなり、ブーメランだ・・・。 nginxがボトルネックなのに、それの改善を出来なかった人が言うことじゃないw

ちなみに、競技終了後に優勝した方にnginxのconfigのについて聞きました。

細かい違いはあれど、大事なhttp2の設定がありました。

    listen 443 http2;

はい、これを書くだけでhttp2になるみたいです。

ちなみに自分はこの対応を入れるだけで、優勝スコアと同等の25万点に到達しました。

く、悔しい・・・。

アプリケーションの改善はほぼ完璧だったということでしょう。

自分のインフラ力のなさが露呈する結果となってしまいました。

ですが、自分のボトルネックがわかるのもISUCONの面白いところで、どういう知識を増やせばアプリケーションを高速化できるかっていうのがかなり客観的にわかります。

実際のISUCONはチームなので、得意な分野がそれぞれ違う3人が集まったほうが強いでしょうね。

なぜhttp2にするとnginxのボトルネックが解消するのか

これは別記事で書こうと思います。

優勝スコア25万点に到達できる実装

ここにあります!!

github.com

優勝した方との差分

おそらくですが、優勝された方のコードは公開されていないので優勝インタビューから推測するしかないですが、差分は以下ぐらいだと思います。

  • レスポンスをcacheするんじゃなくて、静的なファイルとして書き出して、nginxで返す

っていうのは斬新でした。僕は思いつきもしなかったです。

謝辞

改めて、問題を作成されたshowwinさん、会場提供、最高の弁当提供、インスタンス提供してくださったscoutyさん ありがとうございました!

まとめ

  • やっぱりISUCONは楽しい
  • ISHOCONも楽しい
  • 得意不得意があるので、お互いをカバーできるチーム構成が良さそう

*1:今年からお一人様枠が出来ましたが

*2:といいつつ、自分もISUCONに参加したことのない初学者ですが・・・

*3:これ系のコンテストで、デファクトじゃない実装使われるのだいぶ厳しい・・・。GolangはうすいWAFが使われるイメージがある。

*4:1人で8時間のコンテストだから別に良いけど、本家ISUCONだとこうはいかないだろうな〜と・・・

*5:趣味Gopherなので、お目にかかる機会はあんまりありませんでした。便利ですね

*6:これができるのもオープンなベンチマーカーのおかげ

*7:まだ、Too many connectionsが出ていたようです