haml の高速なレンダリングエンジン faml を書いた

haml との互換性にはかなり気を使っているけど、一部意図的に非互換にしていたり、正確な仕様がわからず再現できていない箇所があったり、haml の奇妙な挙動が直っていたりして、完全に全く同じ動作にはなっていない。

先日 faml を本番に投入して今も動いている。faml 導入にあたって実際にはアプリケーション側のビューを一部書き換えたけど、大量のビューがある中での変更点は十分少なかったと思う。

なお、このエントリ内での「元々の haml」は haml 4.0.6 を指している。過去のバージョンは知らない。

どれくらい高速なのか

元々 faml を書き始めたきっかけは「haml と slim に文法的に大きな差があるわけではないんだし、slim と同程度高速な hamlレンダリングエンジンは書けるはず」という点だった。 なので、slim と同じくらい高速ではあるものの、slim 以上に高速というわけではない。ただ、faml は一部 C 拡張を利用しているので slim よりも若干高速なケースは存在する。

マイクロベンチは faml のリポジトリ内で rake benchmark すれば実行できる。 travis-ci の after_script で rake benchmark を実行しているので、そこで結果を見ることができる。 例: https://travis-ci.org/eagletmt/faml/jobs/57138591

なぜ高速なのか

まず slim と同じバックエンドの temple を使っている。 これはシンプルながら強力なライブラリで、temple が定めた S 式にコンパイルすると temple がそれを Ruby のコードに変換してくれ、更に S 式を変換するフィルタを間に挟むことができる。 このフィルタにより最適化を行ったり、独自の文法を導入しつつそのコンパイラを挟んだりできる。

また、属性の描画を高速化するためになるべく静的にコンパイルするようにしている。 例えば %input{checked: false} とあったらリテラルしかないので静的に <input> という文字列まで作ることができる。 これを達成するには Ruby のコードを解釈する必要があるので、そのためのライブラリとして parser を使っている。 Ruby のパーサとしては標準ライブラリの ripper や ruby_parser があるけど、僕は parser が一番使いやすいと思っている。 各 AST ノードに場所の情報がちゃんと入っていたり、その場所に書いてあったコード片を文字列として取り出せるのがいい。

属性を静的にコンパイルできなかった場合は実行時に描画することになるが、この部分で C 拡張を使っている。 これは slim には無い特徴なので、動的な属性が多く存在するようなページでは slim よりも若干高速になるかもしれない。

実際に Web アプリケーションが高速になるのか

完全にアプリケーションによると思っていて、1ページ内で多くのビューをレンダリングしていて haml が支配的である場合も、そうでない場合もある。 多くの場合、スロークエリを改善したり、適切にキャッシュを導入したり、アルゴリズムを改善するほうがずっと効果がありそう。 ただ、faml は haml と高い互換性を維持しているので、アプリケーションコードを変更せずとりあえず faml に入れ替えてみるだけで、一部のページで効果があるかもしれない。この点は魅力的だと思う。

非互換な部分は何なのか

属性に Hash を渡したときの挙動

元々の haml では %span{foo: {bar: 'baz'}}<span foo-bar='baz'></span> となるが、faml では <span foo='{:bar=&gt;&quot;baz&quot;}'></span> となる。 ただし、%span{data: {bar: 'baz'}}<span data-bar='baz'></span> となる。 このように、Hash を渡したときは data 属性の場合に限り hyphenate され、それ以外は単に to_s される。

高速化のために意図的にこのような非互換性を導入している。 Hash の hyphenate は属性の描画の中では比較的重いものなので、なるべく to_s だけで済ませたい。 この挙動は data 属性のときにしか普通使わないはずだと思い、data 属性を特別扱いしている。

ちなみに、一部の属性を特別扱いするのは元の haml にも存在して、idclass が該当する。 id に Array を渡すと join("_") した値がセットされ、class に Array を渡すと join(" ") した値がセットされる。 class のほうの挙動を知ってる人は多そうだけど、id のほうは知らなかった人も結構いるんじゃないかと思う。

常時自動 html エスケープ

元々の haml では Rails プロジェクトでなければデフォルトでは自動 html エスケープが無効になっている。 たまに sinatra を使ったときにはまったりするので、faml では自動 html エスケープを常に有効化している。デフォルトを無効にする手段は提供していない。 Rails プロジェクト以外で html エスケープを無効化したい箇所では != 等の文法を使う。

ugly モードのみ

元々の haml では html をインデントするかどうかを示す ugly というフラグがあったけど、faml では常に ugly、つまりインデントを行わない。

Haml::Helpers 無し

succeed や surround といった haml 固有のヘルパメソッドが存在していたけど、faml では preserve 以外は提供していない。 preserve は使い所がまれに存在するけど、それ以外は無くても困らないと思って外した。 もし他に本当に必要なヘルパメソッドがあったら issue を立てて教えてください。

Object reference 無し

この機能を使ってる人いるんだろうか…… http://haml.info/docs/yardoc/file.REFERENCE.html#object_reference_

whitespace removal の仕様

haml には >< によって空白を取り除く文法があって、これの詳細な仕様が不明で再現しきれてない。 もし明らかに間違っているものを見つけたら issue を立てて教えてください。

元々の haml 側の挙動も怪しくて、例えば

%div<
  hello
  world
%div<
  #{'hello'}
  world

を render するとどうなるかというと、

<div>hello
world</div>
<div>helloworld</div>

になる。faml は前者の挙動が正しいと判断し、

<div>hello
world</div>
<div>hello
world</div>

となるようにしている。

haml のバグ、あるいは一貫性の無い仕様

plain フィルタに interpolation が含まれていたときの空行

whitespace removal で触れたケースに似てるけど、

:plain
  hello
  world
%br
:plain
  #{'hello'}
  world
%br

を ugly mode で render すると、元々の haml では

hello
world
<br>
hello
world

<br>

となって、謎の改行が入る。faml ではこれも haml のバグだと判断し、

hello
world
<br>
hello
world
<br>

となるようにしている。 ちなみに markdown フィルダでも同様の謎の改行が存在する。

空の plain フィルタでの空行

また、同じく :plain において、:plain の中身が空だったときに空行が入るかどうかに差がある。

%br
:plain
%br

これを元々の haml で render すると

<br>

<br>

になる一方、faml では

<br>
<br>

になる。

! または & で始まるテキスト

haml において、行頭の ! または & には意味があり、それぞれ html エスケープをしない、する、という意味を持つ。 != ではなく ! だけの場合は、その後にくるテキスト内の interpolation において html エスケープをするかしないかを制御する。

%span!hello
!hello
%span! hello
! hello

を元々の haml が render すると

<span>hello</span>
!hello
<span>hello</span>
hello

となり、タグ付きの場合は ! の後に空白がなくても !haml の記号として解釈される一方、タグ無しのテキストの場合は ! の後に空白がないと haml の記号として解釈されない。

faml では

<span>hello</span>
hello
<span>hello</span>
hello

となり、どちらの場合も行頭の !haml の記号として解釈する。& の場合も同様。 !& で始まるテキストを書く場合は、行頭を == または \ でエスケープする必要がある。

まとめ

既に haml から slim に移行してしまったり、最近だとクライアントサイド JS での描画やモバイルアプリケーションがメインになってサーバサイドは単なる JSON API サーバになってるところも多そうだけど、もし haml が好きで使ってるけど遅いのが気になっている人がいれば是非 faml を試してみてほしいです。

テンプレートエンジンとバックトレース

最近 haml の別実装 を書いてみていて、コード生成部分でバックトレースのことを考える必要があることに途中で気付いて、haml や slim が生成するコード中の謎の改行の理由がわかった話。

Ruby でよく使われる HTML テンプレートエンジンとして hamlslim がある。 どちらのテンプレートエンジンも、大まかなしくみとしては、

  1. ソース言語から Ruby のコードを生成 (コンパイル)
  2. 生成した Ruby のコードを eval してメソッドに変換
  3. render の際は、適切にインスタンス変数やローカル変数等を与えてメソッド呼び出し

という実装になっている。

haml も slim も、テンプレート中に (ほぼ) 任意の Ruby の式を埋め込むことができる。 すると、テンプレートの render 中に例外が発生することも当然ありうる。 このとき、バックトレースにはちゃんと例外の発生箇所が記録されていてほしい。

テンプレートエンジンのソース言語は Ruby ではないので、例外発生箇所を正しく記録するためには、Ruby の式が埋め込まれる箇所に関してはソース言語とコンパイル後のコードの間で行番号を一致させる必要がある。 たとえば、

%div
  %ul
    - @items.each do |x|
      %li= 'item: ' + x

という haml テンプレートに対して、haml 4.0.6 は次のようなコードを生成する。

_hamlout.buffer << "<div>\n<ul>\n";

@items.each do |x|
_hamlout.buffer << "<li>#{_hamlout.format_script_false_true_false_true_false_true_true(('item: ' + x
));}</li>\n";end;_hamlout.buffer << "</ul>\n</div>\n";;_erbout

このように途中に謎の空行を挟みつつ、Ruby の式が埋め込まれている @items.each'item: ' + x に関してはソース言語と行番号が一致するようにコンパイルされている。 この対応のおかげで、主に開発中に例外が発生した際のバックトレースがテンプレート言語内であっても正確に表示できるようになっている。

C のコードを生成するようなツールだと #line ディレクティブを使って行番号をあわせていることがあるけど、Ruby にはそのようなディレクティブは無いので、コンパイルする際にがんばって行番号をあわせる必要がある。

Unicorn の graceful restart と環境変数

Unicorn の graceful restart は無停止でのデプロイを可能にして非常に便利だが、fork を用いて実装されている都合で古いプロセスから新しいプロセスに環境変数が引き継がれるため、そのことに起因するトラブルがいくつかある。

dotenv の設定が書き変わらない

設定情報を dotenv で管理している人も多いと思うけど、環境変数を使っているので罠がある。

例えば最初に .env に MEMCACHE_SERVERS=memcache-server-001:11211 と書いてあったとする。 このとき Unicorn を起動すると、dotenv によって MEMCACHE_SERVERS=memcache-server-001:11211環境変数に追加される。

その後、接続先として memcache-server-002:11211 を追加したくなって .env を編集して MEMCACHE_SERVERS=memcache-server-001:11211,memcache-server-002:11211 に変える。 ここで Unicorn を graceful restart すると、古い MEMCACHE_SERVERS の値は次のプロセスにも引き継がれ、dotenv はデフォルトでは既にある環境変数を上書きしないため、新しいプロセスでも MEMCACHE_SERVERS の値は古いままで、memcache-server-002:11211 が追加されない。

Bundler のバージョンが上がらない

Bundler環境変数 RUBYLIBRUBYOPT を指定することで、Ruby の起動時に Bundler を読み込ませセットアップを行うようなしくみになっている。 RUBYLIB には初回起動時の Bundler の libdir が入っており、Unicorn を graceful restart してもこの環境変数が引き継がれるため、同じく引き継がれた RUBYOPT=-rbundler/setup によりずっと初回起動時の Bundler の libdir から bundler/setup がロードされ続ける。

解決策

どちらも before_exec でなんとかすることができそう。

dotenv については Dotenv.overload を呼ぶことで .env の内容で環境変数を上書きできる。 なので、before_exec でこれを呼ぶことによって新しい設定が使われるようにできる。 もし .env 以外の方法でセットしている環境変数があった場合、そこでセットされたものよりも .env が優先されるようになってしまうけど、まぁ .env 使ってるときにそういう環境変数は無いと思う。

before_exec do |server|
  Dotenv.overload
end

Bundler については RUBYLIB を最新の Bundler を指すように書き換えてやればいいわけで、ややトリッキーな方法だが以下のような before_exec を書くことでできそう。 もしデフォルトで RUBYLIB, RUBYOPT, GEM_HOME が存在するような環境なら、env.delete のかわりにその値をセットするようにすればいいはず。

before_exec do |server|
  env = ENV.to_hash
  %w[RUBYLIB RUBYOPT GEM_HOME].each do |key|
    env.delete(key)
  end
  rubylib = IO.popen([env, 'bundle', 'exec', 'ruby', '-e', 'puts ENV["RUBYLIB"]', unsetenv_others: true], &:read).chomp
  ENV['RUBYLIB'] = rubylib
end

追記

RUBYLIB を出力するときにわざわざ ruby を使う必要は無いという指摘があった。たしかに bundle exec env でよさそう。

before_exec do |server|
  env = ENV.to_hash
  %w[RUBYLIB RUBYOPT GEM_HOME].each do |key|
    env.delete(key)
  end
  rubylib = IO.popen([env, 'bundle', 'exec', 'env', unsetenv_others: true], &:read).slice(/^RUBYLIB=(.+)$/, 1).chomp
  ENV['RUBYLIB'] = rubylib
end

pt3_drv を使ってる人は Linux 3.18 以上に上げるときに注意

Linux で PT3 を利用している人は pt3_drv を使ってる人が多いと思うけど、Linux 3.18 から earth-pt3 という DVB 版の PT3 ドライバが入るようになって、そのままだと pt3_drv と競合して /dev/pt3video* が生えてこなくなる。

% uname -r
3.18.2-2-ARCH
% zgrep CONFIG_DVB_PT3 /proc/config.gz
CONFIG_DVB_PT3=m

DVB 版に移行してもいいと思うけど、今まで通り pt3_drv を使い続けたい場合は earth-pt3 を blacklist に入れておけばいい。

% cat /etc/modprobe.d/earth-pt3.conf
blacklist earth-pt3

2014年の思い出

しゃかいじんいちねんめ

新生活

無事ストレートで修士課程を終えて社会人になった。 それに合わせて3月に引っ越した。飲み会等でオフィスやその付近でぐだぐだしていても歩いて帰れるので便利。 部屋の広さとか立地とかそれに対する家賃とかには満足してるんだけど、浴室にやや不満があるので遠くないうちにまた引っ越すかも…… 大学で地理的に離れてしまった地元の友人の中に就職のタイミングで上京してきた人もいて、そういう人と会いやすくなったりした。

仕事

最初の一ヶ月は研修受けたりほうれん草収穫したり1週間で Android アプリ作ったりしてた。 その後、Rails のバージョンアップに関連した作業をしたり、アセットまわりを改修したり、小さい Web アプリケーションを golang で書いて Docker を使って動かそうとしたりしていた。 だいたい6, 7割は Web アプリケーションを書いてて、2, 3割が puppet や itamae 書いたりトラブル・アラート対応したりといったかんじだったと思う。 Web アプリケーションを書くといっても新規に機能を追加することはあんまり無く、レガシーっぽいところに対処したり、独自のモンキーパッチでレールから外れたり gem を更新しにくくなったりしているところを何とかしたりしていた。

LT

イベントでの発表は実はこれまで一回だけあったんだけど、今年は RubyKaigi 2014 LTRails複数DB Casual Talks の2回あって、どちらも switch_point の話をした。 プレゼンに対して苦手意識があって、実際伝わりにくい発表だったと思うので、伝え方とか自慢の仕方とかをもっと意識したい。 まぁ switch_point に関しては使わずに済むならそれに越したことはない gem なので、これくらいでちょうどいいのかもしれない……

録画

PC で録画し始めてから去年までずっと使い続けてきたスクリプト群をゼロから書き直した。 これにより同じ番組であっても異なる局で重複して録画するようになり、さらに引越しの機会に BS も視聴できるようになったので、ディスクの消費スピードが高まった。 各局で録画するので、L字やテロップがあったときでも適当に補完できるようになった一方、副作用としてテロップのある回を録画する機会が増えて、あえて残すようになった。 社会人になってから時間をとりにくくなったので実際に視聴する番組は更に減った。

アニメ

いなりこんこん恋いろは、アイカツラブライブ2期、プリズマイリヤ2期、東京喰種がよかった。 あと悪魔のリドル、グリザイアの果実も楽しめた。 まだ放送中だけど、SHIROBAKO、クロスアンジュもよさそう。 社会人になったので積極的に円盤を集めていきたい。

漫画・ラノベ

2月くらいから全部 bookwalker で買うようになった (それまでは Kindle Store で買ってた)。 今年になってから読んだ作品でアニメ見てからじゃないものの中だと citrus がすごくよかった。 数年前で止まってたコップクラフトの新刊が出たのも嬉しかったけど、なんか繋ぎみたいな話だったので早く次が欲しい。

Rails複数DB Casual Talksで話した

先日行われた Rails複数DB Casual Talks というイベントで複数 DB に関連するつらい話をした https://speakerdeck.com/eagletmt/fu-shu-dbtorails 。 r/w splitting を行う switch_point については以前の記事でもう少し詳しく書いてます。

質疑のときに出ていた複数アプリケーション間のモデルの共有方法についてちょっと補足。 僕は今年の新卒入社なのでそれ以前にどうやっていたかはよく知らないけど、本体と管理画面のようにモデルを共有するくらい密に結合しているアプリケーションにおいて1つのリポジトリに入れることは、つらさはあるけど理にかなっていると思う。 ちなみに以前は git submodule で共有していたこともあったらしいです。

gem とか git submodule とかでうまく分けられている事例があったら話を聞きたい……

複数アプリケーション + 複数 DB で不必要な establish_connection をしなくする方法、つまり必要なモデルだけロードする方法は1つわかっていて、App 1 から使われるモデル、App 1 と App 2 から使われるモデル、App 2 と App 3 から使われるモデル、…… みたいに細かく Rails Engine として切り出して、それらに合わせて各アプリケーションでそれぞれ必要なものを require するというもの。 ただ、この方法だとモデルファイルが1つのリポジトリの中で色んなディレクトリに散在してしまうことになって、それはそれでイマイチ感がある。 というわけで何かいい方法ないですかねーという話を最後にしたんですが、この状況に悩んでいるのはうちだけな気はしてます。

ISUCON4 本戦に参加して8位でした

ISUCON4 にクックパッド選抜の†空中庭園†《ガーデンプレイス》として @ryot_a_rai@__gfx__ と参加し、結果は8位でした http://isucon.net/archives/41187491.html 。 使用言語で大きくスコアが分かれることはないだろうということで、3人が共通して慣れている言語として Ruby を選びました。

最初に試しにベンチマークを一回実行しつつ app.rb を読んで、一回の実行だけでかなりスワップしていることと、入稿された動画が Redis に保存されていることに気付いて、まずそれを何とかしようとした。 LTSV 形式で書かれた nginx / Apacheアクセスログからレスポンスタイムの情報を出す access-log.rb を用意して、その結果から動画の配信が支配的だということがわかった。 Ruby の初期実装では全く使われていない ADS_DIR という意味深な定数があったので、そのディレクトリへ動画をファイルとして保存するように変更し、Ruby ではなく nginx が返せるようにした。 同時に、CPU コア数に対して多すぎる unicorn のワーカ数を減らしたり、unix domain socket を使うようにしたりする変更がされていた。

別々のサーバがそれぞれローカルにファイルを置くとなると、ファイルをどう配信するかが問題になる。 一番最初は、各サーバに ID を割り当て、動画がアップロードされたときに Redis に自分のサーバ ID も含めて保存するようにし、GET /slots/:slot/ads/:id/asset されたときにその動画がどのサーバにあるのか Redis から引いて、そのサーバへプロキシするようなものを golang で用意するような構成を考えていた。 しかしその構成を実装する前に、動画へのパスは GET /slots/:slot/ad が返す JSON に含まれている URL で決まるのでは? という話が出て、試しにその API から返す JSON に適当なクエリストリングをつけてベンチマークを実行したところ、クエリストリングがついた状態でアクセスがきたので、GET /slots/:slot/ad を変えれば動画のエンドポイントは変えられることがわかった。 そこで、JSON に含める動画の URL にサーバ ID を加えることにして、nginx の設定で自分のサーバ ID が渡されたら自分で配信し、そうでなかったらそのサーバ ID のサーバへ proxy_pass するよう設定した。 また、初期実装ではレポートの元となるログがローカルファイルに出力されているせいで複数台の構成でうまくいかないことがわかったので、ログも Redis に保存するようにした。

この状態で再度ベンチマークを実行したところ、CPU がほとんど使われておらず、I/O 負荷も全然高くなくてネットワークがボトルネックとなっていることがわかった。 最初は CPU が弱い1号機を Redis 専用サーバにして、残り2つをフロントかつアプリケーションサーバとして使おうと考えていたけど、3台ともフロントに置く構成でいくことにした。 また、サーバ間の無駄な通信を避けるために、GET /slots/:slot/ad がパスだけではなくホスト名も含んだ状態で動画の URL を返すようにして、nginx 間の proxy_pass が不要な形にした。 この状態で8000点近いスコアを出せて、結局ほぼこのときのスコアのまま終わってしまった。

この後、hiredis-rb を使うようにしてみたり、リダイレクトを一段減らしたり、Linux や nginx の設定でネットワークパフォーマンスを上げようとしたりしたけれども、どれも大して効果は出なかった。 リモートでのベンチマーク実行のスコアが全然安定せず、普通に±1000点くらいのバラつきがあったので、どれが効果があってどれが無いのか判断に困った。 スタンディングを見ても上位チームのスコアの差はだいたい2000点くらいの範囲に収まっていて、リモートのベンチマーク実行の不安定さを考えるとほとんど差が無いと思っていた。

帯域の制限に悩まされているときに、一度30万くらいの異常なスコアを出したチームがあった。 これを実現するには、どうにかして動画を配信せずに済む方法があると思った。 動画を返すときにちゃんと Last-Modified ついてるよなーという確認はしたんだけど、その先の Cache-Control には全然気付いていなかった。

競技後に聞いた話の中では、グローバル IP とプライベート IP の両方を使うという発想は全然なかったなーと反省した。 たしかに NIC 2つあるんだから、帯域の制限で困ってるんだったら両方使う発想は出てもよかった。 とにかく帯域に悩み続けて、メモリや I/O は十分に余裕があって CPU もほぼほぼ idle という状況だったので、app.rb 内の削れる処理に気付いたとしても「でも CPU は超余裕なんだよな……」となって進めなかった。 自分は今回が初めての参加だったけど、悔しいので来年もきっと出ます。