Help us understand the problem. What is going on with this article?

【B4UT Advent Calendar 2018】otoge-connected 高速化の軌跡

More than 1 year has passed since last update.

この記事は B4UT Advent Calendar 2018 の 2 日目です。

一昨年去年とそびえ立つクソ記事で 2 年連続のトップバッターを飾ってしまっていたので、今年は 2 日目にしました。

自己紹介

M1 の omi です。音ゲーサークル的には BMS と SDVX をまあまあやってます

いつもはラボでこんな そんな 感じのことをしています。

otoge-connected の開発メンバーの一人で、最近では discord の #otoge-connected_開発 によく居座ってグダグダ言ってる人です。

otoge-connected とは

https://otoge-connected.com/
こ↑こ↓

七帝戦 2018 を機に公開された音ゲー大会の集計サイトで、今では月例大会の集計にも利用されています。B4UT メンバーなら twitter 経由での提出も可能 です。詳しくは B4UT discord みて
七帝戦 2017 (第 1 回) のときのスプレッドシート管理が死ぬほど辛かった / 辛そうだったため開発されました。
結局 2018 では実数 262 名のユーザが集まるめちゃくちゃ大きな大会になったので、手動管理では無理だったゾ

今回は特に七帝戦の 総合ランキング(詳細) についての話をします。
七帝戦 2018 当時、この総合ランキングは開くのに 15 秒 ~ 20 秒かかっていたクソ機能でした。
いまでは 1 秒くらいで開くと思います。

開かなかったら運が悪いので 1 秒で開くまで F5 押してください

要件

・各機種のポイント ( 1 位 50pt, 2 位 45 pt, 3 位 40 pt ~ 最下位 10 pt ) が一覧で見られること
・提出してない機種は 0 pt として扱うこと
音ゲーマーはせっかち(リアルタイムに更新すること)

キャッシュ化していいんだったら以下の話は全部無駄なんですけどね
一プレイヤーとして新しいリザルトを提出したら即座に更新してほしいので、全部真面目に計算していきたい

ちなみに各機種ごとにおけるポイントはその機種のリザルトが提出された時点で別に計算されてます。
総合ランキング(詳細)ではこれらをいかに速く合計して、ソートして、合計の大きな順に並べるかという話になります。

改善の記録

初期 ver. (2 月)

新しいリザルトオブジェクトをつくって render に投げ、投げた先で N+1 を生成するコードになってました。
これでも動くことは動いていたため確か本番期間中はずっとこのコードだったはず

あとで rack-mini-profiler を導入してみたところ手元で 5 秒とか。すごいコードだ。

変数名がガバガバなのはゆるして

competitions/result.html.erb
<% @compe_ranking.map { |u| %>
    <% usr = User.find(u[0]) %>
    <% meu = Result.new( user_id: usr.id, ranking_id: 12346, rank: @compe_ranking.keys.index(usr.id)+1, pts: u[1] ) %>
    <%= render 'results/result', result: meu, result_type: ( @type ? "total_detail" : "total" ) %>
<% } %>
results/_result.html.erb
<% @competition.terms.map { |meu| meu.rankings.map { |buri| %>
    <td>
    <% if p = buri.result.find_by(user_id: result.user.id ) %>
        <%= p.pts %>
    <% end %>
    </td>
<% } } %>

N+1 消去 ver. (5 月半ば)

ポイントを保持する二次元配列を持っておき、先にまとめて単機種のポイントを持っておくようになりました。
参加者もあとで使うサークルの情報を先読みし、ループも render にコレクションごと渡して処理していました。

この少しあとに @result を空配列から push するのではなく直接構築するようにしたり、
results/_result.html.erb 中のリンクをべた書きするように変えたりしてたけど誤差だよ誤差

ここで TTFB が手元で 1 秒切るくらい、多分サーバ上でも 5 秒くらい?

competitions/result.html.erb
<% ptstemp = Hash.new { |h,k| h[k] = {} } %>
<% if @type %>
    <% @competition.terms.map { |meu| meu.rankings.map { |buri| buri.result.map {|p| ptstemp[buri][p.user_id] = p.pts  } } } %>
<% end %>
<% @result = [] %>
<% i = 1 
    @userplus = User.includes(circles: :users_circles).find(@compe_ranking.keys)
    @compe_ranking.map { |usr_id, pt|
        meu = Result.new( user: @userplus.find{ |usrp| usrp.id == usr_id} , rank: i, pts: pt )
        @result.push(meu)
        i += 1
    } %>
<%= render @result, result_type: ( @type ? "total_detail" : "total" ), mode: @competition.mode, ptshash: ptstemp %>
results/_result.html.erb
<% if result_type == "total_detail" %>
    <% ptshash.each_value{ |p| %>
        <td>
        <%= p[result.user_id] %>
        </td>
    <% } %>
<% end %>

SQL 直打ち ver. (7 月末)

ActiveRecord が遅いということに気づいてしまったので黒魔術に手を出したころ。
その都合上 render に頼らずに、competitions/result.html.erb に全部書くことに。これも高速化に効いていたらしい

同時に @compe_ranking を作る controller の方の処理も SQL に移行していたのでこのタイミングでとても速くなった。
手元で 300 ms 切るくらい、鯖上で 2-3 秒。

competitions/result.html.erb
<%  compe_keys = @compe_ranking.keys
    compe_vals = @compe_ranking.values

    sql = ActiveRecord::Base.send(:sanitize_sql_array,
        ['ここは検閲 でも 120 文字折返しで 5 行くらいあるよ', ids: compe_keys ] )
    usersql = ActiveRecord::Base.connection.select_all(sql)

    # where 節に入れた compe_keys の順に並べ替えたい
    # postgresql だと idx(INT[]) 使えばいいらしいんですけど、モジュール入れてなくて使えなかった
    # このコードは O(N^2) なのでクソアホ、あとで sort_by を使ったオサレなコードに変わってます
    userplus = compe_keys.collect { |id| usersql.detect {|x| x["userid"] == id }}

    userplus.map.with_index { |usr,i| %>

<% #以下ループ内にべた書き %>

最新版 (10 月末)

最新版なのでコードはあんまり出さないんですけど

script のロードを非同期にしたり、SQL 叩く回数をなんとかして減らしたり、
erb のインデントや改行を(可読性の損なわれない範囲で)削ったり。

出力される html のサイズを削るってのは割とシャレにならない威力が出たようで、
それだけで chrome が言うところの content download 時間が 2 割くらいになりました。 2 割減じゃなくて。
これでやっと鯖上 1 秒切り。手元だと F5 連打するとたまに 100 ms を切り始めるくらい。

competitions/result.html.erb
<% #これだけで各機種単体のポイント部分が全部表示される %>

<% #今まで改行 + インデントしながら回してたものが、これだと横一列に並ぶのでとても省スペース %>
<% #現行のソースを見るとどこのことか何となく分かると思います %>
<% @rankings.each{ |rid| %><td><%= @ptshash[usr["userid"]][rid[1]] %></td><% } if @type %>

これから

直接詳細ランキング見る人はもうしょうがないけれど、一般ランキングから詳細ランキングに飛ぶときは各機種のポイントの情報だけうまく送れば別に全部読み直す必要ないんですよね

なんとか非同期でやれないかなあと考えてはいますが… js の勉強からしないといけない

まとめ

定数倍高速化を崇めよ


あと何か要望とか、機能追加の意見とかあったら気軽に声掛けてください

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away