text2svgをつかってfontawesomeをSVG化する

人気アイコンフォント、fontawesomeをSVG化したい。

そんな方にもtext2svgは便利にご利用いただけます。

fontawesomeでは指定のunicode毎にアイコンがフォントの文字として登録されています。

https://fortawesome.github.io/Font-Awesome/

この文字は公式サイト等で確認することができ、このunicodeを出力した文字をtext2svgに食わせれば、 fontawesomeのアイコンをSVG化できそうです。

指定のunicode文字を出力するには、環境によりますがIMEに付属するツールスクリプトなどを利用するとよいと思います。

例えば「github」のfontawesomeをSVG化したい場合。

まずは、fontawesomeをローカルにダウンロードします。

次にlibfreetypeをインストール。

そしてtext2svgをインストールすれば下ごしらえは完了。

rubyの場合は、unicode"U+F09B"を出力するには

ruby -e 'print "\u{f09b}"'
#=> "\xEF\x82\x9B"

unicode=U+F09BUTF-8エンコード出力="\xEF\x82\x9B"を得ることができるので

ruby -e 'print "\u{f09b}"' | text2svg --font=fontawesome.ttf > out.svg

で作成完了です。

いちいちunicodeを調べるのがめんどくさいという方は、手前味噌ですがfaコマンドがご利用いただけます。

ksss9.hatenablog.com

fa github | text2svg --font=fontawesome.ttf > out.svg

先ほどリリースしたv0.0.2では、出力文字の色を変えたり、入力textのencodingを指定できるなど、オプションが追加されています。

よければご利用ください。

text2svgでは、作成されたSVGファイルの権利には一切かかわりません。 作成されたSVGについての扱いはフォントの規約をご確認ください。

テキストの外形をsvgにする、text2svgをRubyで作った

テキストとフォントファイルを与えると、テキストの形をsvgで表現した出力が出てくる夢を見たことがありますよね?

作りました。

github.com

$ text2svg "Hello, World\!" --font="/Library/Fonts/Times New Roman.ttf" > test.svg
$ open test.svg -a /Applications/Google\ Chrome.app

sample

な感じで試すことができます。

freetypeを使っているのでこちらのインストールが事前に必要です。

$ brew install freetype

svgでは、<text>タグで文字を表現するのが普通ですが、表現したいフォントが決まっている場合フォントファイルのダウンロードを必要とします。

テキストの外形をsvgのpathにして<path>タグで表現することができれば、 サーバーでsvgファイルを生成してクライアントにフォントファイルをダウンロードさせること無しに文字を表現できます。

文字の並びやtext-align、改行やkerningにも自前で対応していて、

imagemagickなどで画像化した場合と遜色ない文字並びで扱うことができます。

f:id:ksss9:20151217182838p:plain

(上がtext2svgで作ったsvg、下がimagemagickで作ったpng画像)

実装

内部ではfreetypeAPIを呼んでいます。

freetypeAPIffi経由で呼ぶgemも作りました。

github.com

freetypeはフォントファイルを扱うCのライブラリーで、文字の外形pathやbitmap化なんかができます。

http://www.freetype.org/

https://ja.wikipedia.org/wiki/FreeType

ffiはCと他言語の間をとりもつやつ。

https://github.com/ffi/ffi

https://ja.wikipedia.org/wiki/Foreign_function_interface

TrueTypeだけならttfunkを使えばなんとか外形を取ることができますが、 .otfなPostScriptフォントに対応していません。

freetypeであればTrueTypeとPostScriptフォント両方の形式を同じAPIで扱うことができます。

今回はじめてffiで書いてみたけど、 コンパイル無しで(ffiコンパイルいる)CのAPIが呼べるのはよさげでした。

しかしながらStructの構成は自動でできてくれたらなあという感じ。

ffiは初めてなのでまだまだ至らぬ点もあるのかもしれないです。

ちなみにテストはRubyKaigiのLTで話したrgotを使っています。 メソッド一つづつテストするというよりは、ストーリー仕立てでテストするのに向いている気がする。

注意

フォントのSVGを公開してもいいかどうかはフォントの規約に依存します。 作成したSVGを公開する際は、フォントの規約にご注意ください。

RubyKaigi2015でLTした。

「A New Testing Framework Rgot」というタイトルで rgot についての紹介LTをしました。

スライドも公開しました。

発表では時間切れになって見れなかった最後の2ページも乗っています。

以下ダラダラとした話。

LTをやったのは初めて、トーク自体も二回目なのでうまくできたかよくわからないけど、「よかったよ」と言われて嬉しかった。

大学生時代はバンドをやっていた経験があって、「LTはライブだ」「LTを作曲する」みたいな気持ちで考えていたので、わりとノッた感じに仕上げられた。

削った話題としては、

「強い言葉は使わない」

「テストが通ればいいだけの時代は終わった」

「これからのテスト」

みたいなキャッチーなフレーズがあったけど、こねくりまわしているうちに削られた。 お風呂入ってる時はいいアイデアが出やすい。

わりと発表駆動で集中して考えられたので、テスティングフレームワークについて、考えなおすいい機会になった。(ちなみにテストを書くこと自体は好きじゃない)

CFPはもう一つ送っていて、「2つ送ればどれか通るかも、足りなかったらもっとだそう」ぐらいの感じで、 最近作ったいくつかのツールからCFPが募集始まった直後から書いていた。

僕が2個出した時点で合計3個、大江戸RubyKaigiの時点で合計12個しかCFPが出てなかったので、 「確率からすればかなり高い確率で当選するはず。だめならそれが自分のレベルなんだろう。」と思っていた。

もう一つのCFPでは kor について書いていたけど、 大江戸のパーティーでは「tbを読んで打ちのめされてきてください。」と言われていたし、 今思うとrgotの方でよかったと思う。

ちょっと酔ってるのでもっとダラダラした話

ある日、フリースタイルラップバトルの動画を見た。

www.youtube.com

別にラップが大好きなわけではなくて「エミネムの映画でやってたやつか〜」くらいの気持ちで見て、 見た後も特に感想がなかったんだけど、なぜか自分の中でずっと残ってて、

「あれ、LTとフリースタイルラップバトルって似てない?」と唐突に思って、

まあ思っただけで何もしなかったけどLTでラップするのおもしろいと思うので誰かやってみるとよいのではないでしょうか。

font awesomeのunicodeを出力するヤーツ

Font-Awesomeという便利アイコンフォントがある。

これをkeynoteなどで使いたい場合、ローカルにインストールしてから、 各アイコン指定の\uf09bのようなunicodeを打たなければならない。

これを探すのもめんどくさければ、文字をキーボードから作れないので、

わざわざruby -e 'print "\uf09b"'としなければならない。めんどくさい。

何かツールはないかと探してみたところ、

alfredでできる神ツールをみつけた。

しかしながらalfredの有料プラグインが必要らしく、

それならいっそ簡単そうなので自分でCLIを作ってみることにした。

github.com

$ fa github

とすると、\uf09bというunicode文字がprintされるので、これをMacだとpbcopyするなどしてから貼り付けて使う。

全リストを表示する機能もあるので、

$ fa --list | peco | cut -f2 | pbcopy

のようにすれば雑に名前を打ってそれっぽいものを探すことができる。

アイコンの見た目がわからないのが難点か……。

シェルのフォントを一時的に変えることができれば……できるのか?

mgem3連投

mruby関連、最近は全然さわってなかったので少しだけアップデート。

mgem3連投

https://github.com/ksss/mruby-signal

昔作ったやつ。CRubyのsignal.cのほぼコピペ。 コードはコピペだし公式っぽい名前なので恐れ多くてブログにすら書いてなく完全に無告知だったけど、 Trafficから使われている予感を感じていたので登録。

https://github.com/ksss/mruby-rake

昔作ったmrubyでrakeできるようにするやつ。 恐れ多くてmgemにしてなかったけど、1年たっても他者の実装が現れてないっぽいので出してみた。

https://github.com/ksss/mruby-research

昔ブログにも書いたメタmrubyみたいなやつ。 mrb_stateから、GC関連の情報がmrb_gcというstructに切り出されていたため動いてなかったので、ついでに直した。

経緯

こんなこと言ってたら

hb.matsumoto-r.jp

という、おそらくアンサーソングをいただいたのでほんとすみませんという感じで登録した。

とはいえ意外と2年前くらいの自分が主要なのは登録してたのでエライ。

みんなもmgem作ろう!あれやこれらが全然ないぞ!

OSS推進フォーラム

OSS推進フォーラム アプリケーション部会の文章にも自分のプロダクトがリンクされてたっぽい。

http://ossforum.jp/jossfiles/mruby%E3%81%A8CRuby%E3%81%AE%E6%80%A7%E8%83%BD%E6%AF%94%E8%BC%83(%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88).pdf

P.S.

Add mruby-signal by ksss · Pull Request #135 · mruby/mgem-list · GitHub

P.S. I often use your mgem in my software. It's useful, thanks!

というわけで少しは人の役に立ってるっぽい。わーい。

korでテーブル変換サーバーが書けてた

ちょっと思いつきで、korで遊んでみた。

github.com

KorはRubyのgemだ。Rubyは2.0.0から対応している。

これからのプログラムを走らせる準備をしておく。

$ gem install kor kor-output-json

短いサーバープログラムを書く。

server.rb

#! /usr/bin/env ruby

require "socket"
require "kor"

gs = TCPServer.open(8080)
addr = gs.addr
addr.shift
printf("server is on %s\n", addr.join(":"))

while true
  Thread.start(gs.accept) do |s|
    Kor::Cli.new([
      *s.gets.chomp.split(/\s+/),
      *s.gets.chomp.split(/\s+/)
    ], s, s).run
    s.close
  end
end

korがcliに特化しすぎたインターフェースにしたおかげで、 cliじゃなくてもKor::Cliが使いやすくなっている。(良いんだか悪いんだか) Kor::Cli.newの第二引数と第三引数は、getsputsを持ってさえいればいいので、 IOオブジェクトでもStringiOオブジェクトでもいい。もちろんTCPSocketでも。

サーバーを走らせておく。

ruby server.rb

試しにcsvからjsonへの変換をやってみる。 1秒ごとに1行のcsvデータをサーバーへ送っているだけだ。

$ ruby -e '$stdout.sync=true; puts "csv"; puts "json"; puts "foo,bar,baz"; 10.times { |i| puts [i*10,i*20,i*30].join(","); sleep 1 }' | nc localhost 8080
{"foo":"0","bar":"0","baz":"0"}
{"foo":"10","bar":"20","baz":"30"}
{"foo":"20","bar":"40","baz":"60"}
{"foo":"30","bar":"60","baz":"90"}
{"foo":"40","bar":"80","baz":"120"}
{"foo":"50","bar":"100","baz":"150"}
{"foo":"60","bar":"120","baz":"180"}
{"foo":"70","bar":"140","baz":"210"}
{"foo":"80","bar":"160","baz":"240"}
{"foo":"90","bar":"180","baz":"270"}

1秒ごとに変換された結果が返ってくる。*1

役に立つかはともかく、楽しい。

*1:ncを今回はじめて知った。

ISUCON5の予選を徹底的に復習する

ISUCON5の予選に参加して、圧倒的な差で負けたので、 この悔しさをバネに復習して、自分の力にしたいと思う。

今回のファイトではアプリの修正が特に重要であったように思う。

そこでアプリの修正に焦点を当てて、「こうすればよかった」を追っていき、自分のものとして習得したい。

とはいえ、本番とまったく同じ状況を作ることはできないので、ローカル環境でベンチマークを走らせて、簡易に得点を見ていくことにする。

ISUCON5の予選で使われたアプリのコードとベンチマークのコード、gce用のイメージはすでに公開されているのでこちらを使う。

isucon.net

なお、極力アプリの修正に集中するため、nginx.confやmy.cnfはいじらない。unicornのworker数すらいじらない。 インフラはせいぜいテーブルにインデックスを貼る程度とする。

これは、ISUCON予選本番で、やたらインフラに時間をかけてアプリの修正を怠った自分への戒めである。

なお、全作業コードはこちらで公開している。

github.com

環境準備

まずはdatabaseをdumpしてローカル環境を用意する。

gcpSSDインスタンスを選んでmysqldumpを実行する。このとき、gzip圧縮も行うことで、ディスクI/Oを減らす。

mysqldump isucon5q | gzip > isucon5q.sql.gz

磁気ディスクでgzipなしで実行すると、30分以上かかってしまうが、SSD+gzipだと3分ほどで終わる。

これをローカルにコピーしてdatabaseを作っておく。

mysql -u root -e 'create database isucon5q'
mysql -u root isucon5q < isucon5q.sql

これでアプリを動かすことができるようになった。 なお、アプリの言語はRubyとする。

ベンチマーク

さっそくアプリを立ち上げてみても、何がボトルネックなのかわからない。

unicornのログを設定して、レスポンスタイムを記録しておき、ベンチマークを走らせてみる。

https://github.com/ksss/isucon5q-fukusyu/commit/6534e5d614e7dfb73f72388ef3ce4270eb66ab58

ベンチマークのビルドは、ドキュメント通りにやればできた。

アプリ側

RACK_ENV=deployment bundle ex unicorn -c unicorn_config.rb

ベンチマーク側(isucon/isucon5-qualify/bench)

$ jq '.[0]' < ../webapp/script/testsets/testsets.json | gradle run -Pargs="net.isucon.isucon5q.bench.scenario.Isucon5Qualification localhost:8080"

結果

{
  "valid" : true,
  "requests" : 503,
  "elapsed" : 65528,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 367,
    "redirect" : 135,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

successは1点、redirectは0.1点なので、初期スコアは大体370点ぐらいだ。

この点数はあくまでローカル環境での実行結果なので、ディスクはSSDだし、CPUも8 coreだ。

なので、これからの比較として見て欲しい。

ベンチマークの結果を見てみる。

いろいろ便利ツールはあるみたいだけど、とりあえず以下でもだいたい分かる。

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.6953 - - /
0.6960 - - /
0.6987 - - /
0.7006 - - /
0.7077 - - /
0.7130 - - /
0.7184 - - /
0.7193 - - /
0.7223 - - /
0.7265 - - /

なるほど、とにかく/が遅いようだ。

なお、今後も同手法でベンチマークを走らせる。

SQLログを出す

アプリのボトルネックは大体SQLだろうと決めつけ、 どんなSQLが出ているのか知りたいところ。

しかしながらmysql.cnfをいじるのはめんどくさいし、はやい順に並べたり、何本のSQLが流れているのか数えたりしたい。

そこで、mysql2-clientモンキーパッチを作った。

github.com

指定されたSQL、backtrace、実行時間を記録してためておける。これをafterフックへ導入する。

https://github.com/ksss/isucon5q-fukusyu/commit/240e4c9f702a28dcb15e680dc6f527a2c7b1e934

ログにはこう出ていた。

...
SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;    0.124001s
path:/  sql:1232    0.435481s
127.0.0.1 - - [12/Oct/2015:15:51:53 +0900] "GET / HTTP/1.1" 200 16637 0.7254

なるほど、全体では0.72s、SQLだけだと0.44s、1232本のSQL、一番遅いSQLまでわかった。

友達チェックを改善

一番遅いSQLは自分の友だち全てをとってくる部分だが、1232本のSQLうち、ほとんどがrelationsへのSELECTだった。 一つ一つは0.0002s程度だが、これが1000本あれば0.2sになる。 最遅SQLも含めて、SQL取得にかかるほとんどrelationsにかかっているようだった。 このrelationsを改善すれば、高速化を期待できそうだ。

問題のSQLを吐いている部分はis_friend?という、自分と相手が友達かどうか調べる部分だ。mysql2-client-general_logではbacktraceもしこんであるので、SQLの発生場所も容易に特定できる。

コードを読むと、なるほど、entriesを1000件とってきて友達じゃないものを弾く部分と、commentsを1000件とってきて友達じゃないものを弾く部分がくさいようだ。

https://github.com/ksss/isucon5q-fukusyu/blob/240e4c9f702a28dcb15e680dc6f527a2c7b1e934/app.rb#L189 https://github.com/ksss/isucon5q-fukusyu/blob/240e4c9f702a28dcb15e680dc6f527a2c7b1e934/app.rb#L197

と、ここで安易にJOINすればいいじゃんと取り組むと、ORを含んだ難しいSQLに悩まされて時間切れになった僕達のチームの二の舞いだ。

明日への教訓: 落ち着け、まわりをみろ、コメントをつけまくってもいい、コードを読め

もう少し落ち着いて周りを見ていると、気になるところが2つ見えてくる。

友達一覧はあとで取得しているところhttps://github.com/ksss/isucon5q-fukusyu/blob/240e4c9f702a28dcb15e680dc6f527a2c7b1e934/app.rb#L205-L211と、 友達を追加しているところhttps://github.com/ksss/isucon5q-fukusyu/blob/240e4c9f702a28dcb15e680dc6f527a2c7b1e934/app.rb#L361だ。

友達一覧をとっているなら、わざわざSQLをつかって友達かどうか調べる必要はない。 また、友達を追加しているところでは、one anotherに交互に自分と相手を追加する仕様になっていることが分かる。つまりは自分の友だちを調べるだけならoneだけを見ればいい。

なお、ベンチマーカーがどこまで見ているのかわからない以上、表示されるデータが変わってはいけない。 しかし、表示されるデータが変わったかどうかを知るすべがない。

ここは簡易に、出力されるHTMLを比較するという泥臭い感じでやってみる。

ただし、ベンチマークが初期値を更新してしまうようなので、ベンチマークを走らせる度にHTMLを更新しなければいけないのが難点。

ログインするアカウントを決め打って、ブラウザのコンソールからHTMLをファイルにコピペすることで比較用のデータを作っておく。

テキストのdiffを見るのには、diffyというgemが便利だった。

https://github.com/ksss/isucon5q-fukusyu/commit/ef65e442e0eedbcc3bb9f42f67a5d57e60697bea

明日への教訓: 不安は積み重なり、正常な判断ができなくなり、崩壊する。不安ならテストしろ。

これでいくらいじっても、htmlが変わったら検知できるようになった。

友達一覧情報を、以降でも流用するように改善してみる。

https://github.com/ksss/isucon5q-fukusyu/commit/26d46912fb5c6ecbf0db81edb61a325e4481ea7e

これでログは、こう変化した。

- SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;    0.124001s
- path:/   sql:1232    0.435481s
- 127.0.0.1 - - [12/Oct/2015:15:51:53 +0900] "GET / HTTP/1.1" 200 16637 0.7254
+ SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;  0.132749s
+ path:/    sql:82  0.263436s
+ 127.0.0.1 - - [12/Oct/2015:17:14:59 +0900] "GET / HTTP/1.1" 200 16637 0.4542

最遅SQLは変わらず。SQLの本数は1232->82と激減。SQL時間も、目論見通り約0.17s減。トータル時間もSQL発行毎に、オブジェクト化などのオーバーヘッドがあるのであろう、0.17sよりもさらに0.1s多い、0.27s減となった。

ベンチマークスコアは

{
  "valid" : true,
  "requests" : 712,
  "elapsed" : 67053,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 533,
    "redirect" : 178,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

550点といったところ。+180点になった。

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.4582 - - /
0.4623 - - /
0.4657 - - /
0.4686 - - /
0.4696 - - /
0.4743 - - /
0.4748 - - /
0.4806 - - /
0.4920 - - /
0.5393 - - /

ログはこの通り。やはり、/が遅いので、引き続き/を改善していく。

友達一覧を改善

もっとも遅いSQLは以下のとおりで、0.13sもかかっている。

SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;

EXPLAINしてみる。

$ mysql -u root isucon5q -e 'EXPLAIN SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;'
+----+-------------+-----------+------+---------------+------+---------+------+--------+-----------------------------+
| id | select_type | table     | type | possible_keys | key  | key_len | ref  | rows   | Extra                       |
+----+-------------+-----------+------+---------------+------+---------+------+--------+-----------------------------+
|  1 | SIMPLE      | relations | ALL  | friendship    | NULL | NULL    | NULL | 494134 | Using where; Using filesort |
+----+-------------+-----------+------+---------------+------+---------+------+--------+-----------------------------+

どうみてもフルスキャンだ。

友達一覧を、oneだけの検索にして、oneにインデックスを貼ってみる。

https://github.com/ksss/isucon5q-fukusyu/commit/4995395b8b586f905093fa39732f7afc027242cd

mysql -u root isucon5q -e 'alter table relations add index one(one);'

ログはこうなった。

- SELECT * FROM relations WHERE one = '3657' OR another = '3657' ORDER BY created_at DESC;    0.132749s
- path:/   sql:82  0.263436s
- 127.0.0.1 - - [12/Oct/2015:17:14:59 +0900] "GET / HTTP/1.1" 200 16637 0.4542
+ SELECT user_id, owner_id, DATE(created_at) AS date, MAX(created_at) AS updated
+ FROM footprints
+ WHERE user_id = '3657'
+ GROUP BY user_id, owner_id, DATE(created_at)
+ ORDER BY updated DESC
+ LIMIT 10
+ ; 0.099964s
+ path:/    sql:82  0.132781s
+ 127.0.0.1 - - [12/Oct/2015:19:40:15 +0900] "GET / HTTP/1.1" 200 16638 0.3112

一番遅いSQLが友達一覧の取得から、あしあと一覧の取得に変わり、0.13sから0.10sに変化した。 SQL量は変わらないが、時間は約半分になっている。 全体では0.14s短縮できたようだ。

ベンチマーク結果は

{
  "valid" : true,
  "requests" : 882,
  "elapsed" : 64103,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 672,
    "redirect" : 209,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

約690点。+140点となった。

遅いアクセスパスはやはり/なようだ。

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.3342 - - /
0.3411 - - /
0.3413 - - /
0.3459 - - /
0.3509 - - /
0.3528 - - /
0.3616 - - /
0.3667 - - /
0.3941 - - /
0.4094 - - /

あしあと調整

前回変化した最遅のSQLをEXPLAINで見てみる。

$ mysql -u root isucon5q -e 'EXPLAIN SELECT user_id, owner_id, DATE(created_at) AS date, MAX(created_at) AS updated FROM footprints WHERE user_id = '3657' GROUP BY user_id, owner_id, DATE(created_at) ORDER BY updated DESC LIMIT 10;'
+----+-------------+------------+------+---------------+------+---------+------+--------+----------------------------------------------+
| id | select_type | table      | type | possible_keys | key  | key_len | ref  | rows   | Extra                                        |
+----+-------------+------------+------+---------------+------+---------+------+--------+----------------------------------------------+
|  1 | SIMPLE      | footprints | ALL  | NULL          | NULL | NULL    | NULL | 499597 | Using where; Using temporary; Using filesort |
+----+-------------+------------+------+---------------+------+---------+------+--------+----------------------------------------------+

フルスキャンのようだ。 なれた人ならSQLをみればindexの貼りどころがわかってくるが、今回のISUCONでは磁気ディスクの遅さに悩まされ、むやみにindexをはっていると作業時間のロスになっていた。*1 ここは最小限の作業だけを行う。

mysql -u root isucon5q -e 'alter table footprints add index user_id(user_id);'

これでログはこう変化した。

- SELECT user_id, owner_id, DATE(created_at) AS date, MAX(created_at) AS updated
- FROM footprints
- WHERE user_id = '3657'
- GROUP BY user_id, owner_id, DATE(created_at)
- ORDER BY updated DESC
- LIMIT 10
- ;    0.099964s
- path:/   sql:82  0.132781s
- 127.0.0.1 - - [12/Oct/2015:19:40:15 +0900] "GET / HTTP/1.1" 200 16638 0.3112
+ SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000;    0.011207s
+ path:/    sql:82  0.030177s
+ 127.0.0.1 - - [12/Oct/2015:22:51:01 +0900] "GET / HTTP/1.1" 200 16637 0.2122

最遅SQLが変化し、0.133sから0.011sにまでになった。 SQL量はインデックスを張っただけなので当然変化せず。しかしSQL時間は0.1s短縮されている。 トータルも0.1s減になった。

ベンチマークは約1070点。+380点となった。

{
  "valid" : true,
  "requests" : 1361,
  "elapsed" : 63577,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 1049,
    "redirect" : 311,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

遅いのはやはり/であるのは変わらないようだ。

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.2187 - - /
0.2190 - - /
0.2198 - - /
0.2207 - - /
0.2209 - - /
0.2214 - - /
0.2230 - - /
0.2238 - - /
0.2323 - - /
0.2514 - - /

インデックスが終わったら?

これまでは最遅のSQLをみてindexをはってでスコアを稼いできたが、次の最遅SQLはどうみてもindexで改善しようがない。

序盤はこんな感じでSQLの改善を見ていけばよかったんだと思う。

初歩的なSQLの知識があれば、誰でも解けるような問題だった。コードも大した変更量はない。

この時点にすら自分たちはたどりつけなかったので、やはり落ち着くことが、一番の課題だったと思う。

明日への課題: 大事なことだから何回でも言う。おちつけ。

これからは中盤として、問題も難しくなっていく。

プロファイル

ここいらでプロファイルを取ってみる。

Rubyだとstackprofが有名なようだ。

https://github.com/ksss/isucon5q-fukusyu/commit/b87eba3162b858588b84e3e80c2de9c9ad73997e

これで何度かアクセスしてみてプロファイルを見てみる

$ stackprof /tmp/stackprof/stackprof-wall-*.dump --file app.rb | less

どうもこの辺りが重いようだ。

                                  |   195  |     entries_of_friends = []
 2344   (20.8%) /  2249  (20.0%)  |   196  |     db.query('SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000').each do |entry|
   30    (0.3%) /    30   (0.3%)  |   197  |       next unless friends_map.key?(entry[:user_id])
                                  |   198  |       entry[:title] = entry[:body].split(/\n/).first
                                  |   199  |       entries_of_friends << entry
   16    (0.1%) /    16   (0.1%)  |   200  |       break if entries_of_friends.size >= 10
                                  |   201  |     end
                                  |   202  |
                                  |   203  |     comments_of_friends = []
 3441   (30.6%) /  2836  (25.2%)  |   204  |     db.query('SELECT * FROM comments ORDER BY created_at DESC LIMIT 1000').each do |comment|
   94    (0.8%) /    94   (0.8%)  |   205  |       next unless friends_map.key?(comment[:user_id])
  373    (3.3%) /    60   (0.5%)  |   206  |       entry = db.xquery('SELECT * FROM entries WHERE id = ?', comment[:entry_id]).first
                                  |   207  |       entry[:is_private] = (entry[:private] == 1)
   71    (0.6%) /     7   (0.1%)  |   208  |       next if entry[:is_private] && !permitted?(entry[:user_id])
                                  |   209  |       comments_of_friends << comment
    7    (0.1%) /     7   (0.1%)  |   210  |       break if comments_of_friends.size >= 10
                                  |   211  |     end

これは未だにわからないままの謎現象なのだが、下記コードはunicorn上だと遅い。

Benchmark.realtime{ db.query('SELECT * FROM comments ORDER BY created_at DESC LIMIT 1000').each{ |c| } }

単純なRackアプリを作って計測したところ、 WEBrick 1.3.1では0.011241unicorn 4.9.0では0.108689と10倍ほど差があった。

なんだかわからんがとにかく遅い。

unicorn以外の選択肢を持っていればさらなる検証もできたかもしれないが、ここはSQLを工夫することで回避する。

「commentsのデータが多くてオブジェクト生成に時間がかかっているのかな?」と予想を立て、下記コードを計測してみた。

commentsのcommentcreated_atカラムをのぞいたものと、全て取得する場合だ。

db.query('SELECT id, entry_id, user_id FROM comments ORDER BY created_at DESC LIMIT 1000').each{|c| }
db.query('SELECT * FROM comments ORDER BY created_at DESC LIMIT 1000').each{|c| }

webrick

0.0032s
0.0106s

unicorn

0.0035s
0.1061s

なぜunicornがこれほど差がつくのかまったく謎なのだが、とにかく早くなるようだ。

これは流石に経験則というか、特定の言語の特定のライブラリを組み合わせたときの動作なんて覚えてられないので、ISUCONという場で思いつけたかというとかなり微妙だ。

comments.commentも、せいぜい100文字行かない程度のデータなので、「カラム数×レコード数で時間がかかる」ぐらいのことを覚えるしか無いか。

プロファイル上は、0.21sの30%なので、0.063sぐらいかかっているというあたりを付けて、修正にあたいすると判断するしかなさそうだ。

難しいぞ、ISUCON。

明日への教訓: ISUCON難しい

それではこの現象を利用して、友達チェックやらprivateチェックやらを終えた最大10件だけで再度SQLを投げてcommentを取得するようにコードを書き換える。

https://github.com/ksss/isucon5q-fukusyu/commit/57de06712e3c5adb2d838296cc0a503d2cf5a1ed

- SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000; 0.011207s
- path:/    sql:82  0.030177s
- 127.0.0.1 - - [12/Oct/2015:22:51:01 +0900] "GET / HTTP/1.1" 200 16637 0.2122
+ SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000;    0.012425s
+ path:/    sql:83  0.028977s
+ 127.0.0.1 - - [13/Oct/2015:10:20:44 +0900] "GET / HTTP/1.1" 200 16637 0.1352

SQLを1本増やしたのにもかかわらず、トータルで0.08s減になった。

スコアは約1400点。+330になった。

{
  "valid" : true,
  "requests" : 1774,
  "elapsed" : 63777,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 1364,
    "redirect" : 409,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

/の一番遅かったときの速度改善はいまいちか?

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.2429 - - /
0.2431 - - /
0.2437 - - /
0.2577 - - /
0.2736 - - /
0.2760 - - /
0.2789 - - /
0.2848 - - /
0.2971 - - /
0.3371 - - /

これぐらいにボトルネックがなくなってくると、データ量に気を使う必要があるようだ。

ちなみにcommentsのuser_idにインデックスを貼って、friend_map.keysで検索することでデータ量を抑える方法もありえるが、private対策が思いつかず、結局1000件から減らせなかった。 また、entriesとJOINすると非常に遅くなってしまうなどでこれ以外の改善が思いつかなかった。

このへんも習得してパフォーマンス改善に使おうとするには「経験と勘」みたいなところの積み重ねなのかなあ。

entries改善

                                  |   195  |     entries_of_friends = []
 1427   (63.6%) /  1400  (62.4%)  |   196  |     db.query('SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000').each do |entry|
   12    (0.5%) /    12   (0.5%)  |   197  |       next unless friends_map.key?(entry[:user_id])
                                  |   198  |       entry[:title] = entry[:body].split(/\n/).first
                                  |   199  |       entries_of_friends << entry
    7    (0.3%) /     7   (0.3%)  |   200  |       break if entries_of_friends.size >= 10
                                  |   201  |     end
                                  |   202  |
                                  |   203  |     comments_of_friends = []
                                  |   204  |     comment_ids = []
  123    (5.5%) /    11   (0.5%)  |   205  |     db.query('SELECT id, entry_id, user_id FROM comments ORDER BY created_at DESC LIMIT 1000').each do |comment
|
   13    (0.6%) /    13   (0.6%)  |   206  |       next unless friends_map.key?(comment[:user_id])
   68    (3.0%) /    33   (1.5%)  |   207  |       entry = db.xquery('SELECT * FROM entries WHERE id = ?', comment[:entry_id]).first
                                  |   208  |       entry[:is_private] = (entry[:private] == 1)
    9    (0.4%) /     1   (0.0%)  |   209  |       next if entry[:is_private] && !permitted?(entry[:user_id])
                                  |   210  |       comment_ids << comment[:id]
                                  |   211  |       break if comment_ids.length >= 10
                                  |   212  |     end
   30    (1.3%) /    26   (1.2%)  |   213  |     db.xquery('SELECT * FROM comments WHERE id IN (?) ORDER BY created_at DESC LIMIT 10', comment_ids).each do |comment|
                                  |   214  |       comments_of_friends << comment
                                  |   215  |       break if comments_of_friends.size >= 10
                                  |   216  |     end

commentsの改善によって、ボトルネックがentriesの取得になっているようだ。

このSQLではentries全件をcreated_at順で並べ替え、上から1000件取ってくようだ。

ここの中身はcommentsほど複雑ではないので、friends_map.keysで友達一覧をWHERE INで検索してしまい、10件に絞る。

https://github.com/ksss/isucon5q-fukusyu/commit/3bad2014b299ad6b3281fdad3fd26e933c348038

- SELECT * FROM entries ORDER BY created_at DESC LIMIT 1000; 0.012425s
- path:/    sql:83  0.028977s
- 127.0.0.1 - - [13/Oct/2015:10:20:44 +0900] "GET / HTTP/1.1" 200 16637 0.1352
+ SELECT user_id, owner_id, DATE(created_at) AS date, MAX(created_at) AS updated
+ FROM footprints
+ WHERE user_id = '3657'
+ GROUP BY user_id, owner_id, DATE(created_at)
+ ORDER BY updated DESC
+ LIMIT 10
+ ; 0.002886s
+ path:/    sql:83  0.018291s
+ 127.0.0.1 - - [13/Oct/2015:17:31:10 +0900] "GET / HTTP/1.1" 200 16638 0.0498

トータルで0.05s。さらに0.08s減となった。

スコアは2280点。+880点とかなり上昇した。

{
  "valid" : true,
  "requests" : 2872,
  "elapsed" : 63524,
  "done" : "[{Isucon5InitExecutor},{BootstrapChecker},{Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Load,Isucon5Checker}]",
  "responses" : {
    "success" : 2223,
    "redirect" : 648,
    "failure" : 1,
    "error" : 0,
    "exception" : 0
  },
  "violations" : [ ]
}

遅いパスも/ではなく、/friendsに移行。

$ awk '{print $11 " - - " $7}' < unicorn.log | sort -n | tail
0.1808 - - /friends
0.1808 - - /friends
0.1822 - - /friends
0.1852 - - /friends
0.1871 - - /friends
0.1872 - - /friends
0.1918 - - /friends
0.1924 - - /friends
0.2095 - - /friends
0.2468 - - /friends

では次に/friendsを改善と行きたいところだが、これ以降も似たような手順で改善していけるだろう。

SQL改善が終わればもう後半戦。ここからは未知の領域だが、これまででかなり復習できたんじゃないだろうか。

落ち着いてボトルネックに一つづつ対処していく。経験を積めば、ボトルネックが先読みできて、分担作業ができる。

まとめ

ISUCON5予選の「こうすればよかった」を振り返ってみた。

2本のalterとわずかな実装の修正で//friendsよりはやくなった。

もちろん十分に時間をかけて探っていった修正なので、本番では間に合わなかっただろう。

次に似たようなことが起こったとき、対処をおもいつける冷静さと、今回の僅かな経験がいつか役に立つ日が来るかもしれない。

本線突破勢にとっては「これからが予選問題の本番」というような内容だったかもしれないが、

しっかりと基礎を見なおしてから先に進みたいと思う。

明日への教訓をまとめてみよう。

一つ 落ち着け

一つ 不安は積み重なり、正常な判断ができなくなり、崩壊する。不安ならテストしろ。

一つ とにかく落ち着け

一つ ISUCON難い

良い問題をありがとうございました!ISUCON最高でした!

参考資料

出題担当のkamipoさんによる解説。 非常にためになるので、今回の復習と合わせて習得したい。

isucon.net

ベンチマークは紹介されているとおりに動いて素晴らしかった。 リモートマシンに対して行えば、社内でISUCONの復習会みたいなこともできるかもしれない。

isucon.net

*1:SSDインスタンスでインデックスはったイメージだけつかって磁気ディスクインスタンスに載せ換えるというウルトラCを実行したチームもいる。さすがだ。。。