Hamlコミッターになった
RubyKaigi 2015で「Hamlは遅いしメンテされてないので使わない方がいい」と言ったところ、じゃあ自分でメンテして速くしろということになりコミッターになった*1。
当時から2年ごしなのは、当時のHamlのオーナーがあまりアクティブではなく、最近a_matsudaさんがオーナーになったため。
HamlのTemple化・高速化を行った
Templeというのは、テンプレートエンジンをパイプライン的に構築するためのフレームワークで、テンプレートエンジン用の中間表現とその最適化エンジンを持つ。実装をTempleベースにすると、SlimやHamlitに使われているような中間表現を使った最適化を適用しやすくなる。
コミット権をもらったので、RubyKaigi 2015でマージされないと言っていたパッチを自分でマージし、コード生成とattributeのコンパイルをTemple化しながら高速化した*2。 それ以外にもいろいろ遅い原因を調べて改善した。
Hamlはどのくらい速くなったのか
Haml4 vs Haml5
つい昨日、それらの高速化を含むHaml 5.0.0.beta.2がリリースされた。
同じgem同士であるHaml4とHaml5を同時に比較するためのリポジトリを作っていて、これはslim-template/slimに入っているベンチマークからHTMLエスケープの回数が違う問題を修正したもの。
Ruby 2.4.0でHaml 4.0.7と現在のHamlのmasterを比較すると以下のようになる。 (Travisでの結果)
Rendering: /home/travis/build/k0kubun/haml_bench/templates/slim_bench.haml Warming up -------------------------------------- haml 4.0.7 1.595k i/100ms haml 5.0.0.beta.2 5.497k i/100ms Calculating ------------------------------------- haml 4.0.7 17.011k (± 9.9%) i/s - 84.535k in 5.031171s haml 5.0.0.beta.2 63.100k (± 1.8%) i/s - 318.826k in 5.054222s Comparison: haml 5.0.0.beta.2: 63100.1 i/s haml 4.0.7: 17010.5 i/s - 3.71x slower
というわけで、masterだとHaml4の3.71倍速くなっている*3。本当はmasterじゃなくて5.0.0.beta.2にしたいんだけど、あるバグ修正の際に誤って遅くしてしまったケースがあり、その対応を入れたリビジョンにしているので注意。5.0.0.beta.2だと3.28倍とかになる。
vs Slim, Faml, Hamlit, Erubi…
もともとslim-template/slimのベンチマークがテンプレート言語を超えて比較するベンチマークだったため、Slimなどとの比較もでき、以下のような結果になる*4。(Travisでの結果)
まあまあ縮まったけどまだ遅いですねという感じ。やっていくぞ。
Erubiとは
これは完全に余談だけど、ErubiというERB実装をご存知だろうか。Railsで長らく使われていたErubisは、今年Erubiに置き替わった。
Erubisはかなり前にメンテが止まっていた分frozen string literalが使われていなかったりして遅いんだけど、それがErubiでは解消され*5、ついうっかり僕もErubiのHTMLエスケープを速くしてしまったため、Hamlitとほぼ変わらないパフォーマンスが出るERB実装になっている。
どうやってHamlを速くしたのか
僕は自分のHaml実装を持っているので正直Haml使わないのに何故高速化をやっているかというと、何が原因でHamlが遅いのかに興味があったからである。というわけで、同じ興味を持っている人向けに、どうやって速くしたかを書いておく。高速化の手法自体はRubyKaigi 2015で話したので、以下では個別の話だけ書く。
以下はベンチマークの結果を見て効果があったと思われる順になっている。
1. attributeレンダリング用生成コードの最適化 haml/haml#904
このパッチが最も高速化に貢献しており、その分書くのも一番大変だった。簡単に言うと、以下のテンプレートは
.foo#bar{ class: 'baz', data: 'a' * 3 }
いままでは以下のようにコンパイルされていたが、
_hamlout.push_text( "<div#{ _hamlout.attributes({"class"=>"foo", "id"=>"bar"}, nil, class: 'baz', data: 'a' * 3 ) }></div>\n", 0, false, )
以下のようにコンパイルされるようにした、ということである。
_hamlout.buffer << "<div class='baz foo'".freeze _haml_attribute_compiler1 = 'a' * 3 case _haml_attribute_compiler1 when Hash _hamlout.buffer << _hamlout.attributes({ "data".freeze => _haml_attribute_compiler1 }, nil).to_s when true _hamlout.buffer << " data".freeze when false, nil else _hamlout.buffer << " data='".freeze _hamlout.buffer << ::Haml::Helpers.html_escape(_haml_attribute_compiler1) _hamlout.buffer << "'".freeze end _hamlout.buffer << " id='bar'></div>\n".freeze
_hamlout.attributes
って何、と思うかもしれないが、これはとにかく遅いメソッドであり、これを呼ばなくすると速くなる*6。コードの通り、動的な式('a' * 3
の部分)の結果がHash
じゃない限りはこの遅いメソッドが呼ばれず、また他の場所では可能な限り事前に連結された状態でバッファにconcatされるので、速い。
これをやるためには、ものすごく複雑なHaml attributeの仕様を正確に把握している必要があり大変だった。みんなもこの記事を5秒くらい見て欲しい。
このパッチで追加したHaml::AttributeCompiler
は、互換性を一切崩さずに高速なコードを生成しようとしてものすごく色々なことを考えて書いてあるので語りたいことがたくさんあるんだけど、長くなるので何かのLTのネタとしてとっておく。
2. Haml::Bufferのオプション向けのオブジェクト生成を減らした haml/haml#897
Hamlのレンダリングコードには必ず以下のコードが最初に入っていた。
_hamlout = @haml_buffer = Haml::Buffer.new( haml_buffer, { :autoclose=>["area", "base", "basefont", "br", "col", "command", "embed", "frame", "hr", "img", "input", "isindex", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"], :preserve=>["textarea", "pre", "code"], :attr_wrapper=>"'", :ugly=>false, :format=>:html5, :encoding=>"UTF-8", :escape_html=>true, :escape_attrs=>true, :hyphenate_data_attrs=>true, :cdata=>false } )
これが何を意味するかというと、レンダリングする度に毎回同じString 25個・Array 2個・Hash 1個を生成していることになる。毎回同じならこんなに渡す必要はないので、デフォルトとは違うオプションのみ渡されるようにした。そのため、普通に使っていれば、以下のようなコードが生成される。
_hamlout = @haml_buffer = Haml::Buffer.new(haml_buffer, {})
余計なオブジェクトが作られなくなるので速くなる。
3. attribute値のpreserveの無効化 haml/haml#903
以下のテンプレートは、
%p{ data: "foo\nbar" }
Haml4だと以下のようになるんだけど、
<p data='foo
bar'></p>
Haml5では以下のようになるようにした。
<p data='foo bar'></p>
これに関しては完全に仕様を変えているので全く褒められた改善ではないのだが、このpreserve
と呼ばれる機能はとても遅い。遅いのは、ただでさえgsub
が遅いのにそれ以外にもいろいろやっているからである。
SlimやFaml, Hamlitは後者の挙動で問題なく動いているし、入った時のコミットを見ても何で必要なのかよくわからん仕様なので、遅くなるデメリットの方が大きいと判断し削った。何か意見のある識者は声をかけてほしい。
4. 文字列リテラルのfreeze haml/haml#893
あまり解説する必要はなさそうだけど、静的な文字列をバッファに渡す時に必ず.freeze
がついた状態で文字列を作るため、レンダリング時に文字列オブジェクトが生成しなくて済むというもの。
Temple化をすると静的な文字列は全て:static
という中間表現になるため、これに全部.freeze
をつけていくのが簡単になる。
5. HTMLエスケープの高速化 haml/haml#902
Ruby 2.3でRuby本体のHTMLエスケープメソッドを高速化したので、gsub
ではなくそっちを活用するようにした。その際、古いHamlのものすごく複雑なエスケープの挙動が邪魔になるので、FamlやHamlitと同じごく普通の挙動にした。そこは後方互換性のない変更になってしまうが、流石にこれで困る人はいない気がしている。
HamlitよりHamlを使った方がよくなる?
ならない。Hamlitが速いのはHaml::Buffer
が必要な機能を諦めているのとC拡張があるからで、少なくとも前者をやるのはHaml::Helpers
を消せない都合かなり難しいし、後者はまだボトルネックではないのでやらなそう。
というわけで、Haml特有のヘルパーを使っている人などがHaml 5の想定ユーザーになるが、何らかの理由でFamlやHamlitへの移行を諦めた人には普通に嬉しいリリースなんじゃないだろうか。
僕も最終的にはみんなhaml.gemを使えばいい状態にはしたいが、そう簡単に後方互換性は切れるものじゃないと思うので、結構先になる気がする。
気持ち
FamlやHamlitではなくHamlを使っているアプリがある人はHaml 5.0.0.beta.2を試してみて欲しい。
*1:これは冗談で、a_matsudaさんからお誘いがあり、僕も興味があったのでやりますと言い、メンテをさせていただけることになった。
*2:余談だけど、HamlのTemple化をやりながらTempleを直していたところ、Templeの方のコミット権もいただいた。
*3:僕が変更を加える前のmasterは1.09倍くらい https://travis-ci.org/k0kubun/haml_bench/jobs/205696677
*4:Hamlは先ほどと同じリビジョンで、Erubiも現在のmasterである ad41891 にしてある
*5:なお遅かったのは素のErubisの話で、もともとActionViewはErubisを魔改造していたためRails上ではこの問題はなかった
*6:真面目に説明すると、改善後のコードがコンパイル時に済ませている文字列連結を全部レンダリング時にやっているのが_hamlout.attributesである