Hamlを3倍速くした

Hamlコミッターになった

RubyKaigi 2015で「Hamlは遅いしメンテされてないので使わない方がいい」と言ったところ、じゃあ自分でメンテして速くしろということになりコミッターになった*1

当時から2年ごしなのは、当時のHamlのオーナーがあまりアクティブではなく最近a_matsudaさんがオーナーになったため。

HamlのTemple化・高速化を行った

Templeというのは、テンプレートエンジンをパイプライン的に構築するためのフレームワークで、テンプレートエンジン用の中間表現とその最適化エンジンを持つ。実装をTempleベースにすると、SlimHamlitに使われているような中間表現を使った最適化を適用しやすくなる。

コミット権をもらったので、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での結果)

f:id:k0kubun:20170227151723p:plain:w540

まあまあ縮まったけどまだ遅いですねという感じ。やっていくぞ。

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&#x000A;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と同じごく普通の挙動にした。そこは後方互換性のない変更になってしまうが、流石にこれで困る人はいない気がしている。

github.com

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である