STORES技術基盤グループでインターンをしている@White-Greenです。 この記事では、私がインターンで取り組んでいるアセットコンパイルの高速化について書きます。
背景
Sprocketsは、.jsや.cssのようなアセットをパッケージングするためのライブラリです。 例えばJavaScriptのminifyなどを(他ライブラリへの移譲により)担当するものです。 STORESではproduction環境のためのアセットコンパイルをCI上で行っており、その実行に約420秒かかっていました。 この処理時間がexecjsの性能改善によりある程度の改善が見込まれるということで、調査と改善に着手しました。
execjs
execjsは、RubyからJavaScriptコードを呼び出すためのgemです。 このような感じで、JavaScriptコードを文字列として渡して評価、実行をすることができます。(execjsのREADMEより引用)
require "execjs" ExecJS.eval "'red yellow blue'.split(' ')" # => ["red", "yellow", "blue"]
また、関数定義を持ち越すような用法にも対応しています。
この例(同じく引用)ではCoffeeScriptのコンパイラをあらかじめ読み込んでcontextを生成しておき、コンパイラに含まれるCoffeeScript.compile
関数に対し文字列を渡して呼び出しています。
require "execjs" require "open-uri" source = open("http://coffeescript.org/extras/coffee-script.js").read context = ExecJS.compile(source) context.call("CoffeeScript.compile", "square = (x) -> x * x", bare: true) # => "var square;\nsquare = function(x) {\n return x * x;\n};"
この例を元にすると、コンパイルしたいCoffeeScriptのコードが多数あったり色々な場所からCoffeeScriptのコンパイルを呼び出したい時にcontextを取り回すことでシンプルに実装できる利点があります。
アセットのコンパイル時にSprockets経由で呼び出しているUglifierなどがexecjsを利用しており、ExecJS.compile
にUglifyJSのソースを渡しておき、処理対象の.jsファイルの内容をcontext.call
に渡してminifyを行うような実装になっています。
execjsの内部はほとんどの処理を外部から差し替え可能なランタイムに移譲する実装になっているためランタイム毎に実装の詳細は異なりますが、例えば標準で含まれているNode.jsのランタイムでは
ExecJS.compile
に渡されたコード(例えばCoffeeScriptコンパイラやUglifyJSのコード)をcontext内部に保存しておきcontext.call
に渡されたコードの前に結合して実行したいコードを生成- Node.jsプロセスを起動してコードを評価
- 結果をRuby側で受け取ってパース
というプロセスによりJavaScriptコードの実行を可能にしています。
ExecJS.compile
に渡されたコードは一度評価して使い回すことが可能であるにも関わらず、context.call
毎に評価を行っている点です。
上のシーケンス図でいうと、context.call
毎にNode.jsプロセスの起動とinitial_sourceの評価をしている部分は1度のみに抑えられないかということです。
この理想的な挙動である一度評価して使い回す実装を行ったランタイムとしてexecjs-fastnodeがあります。
execjs-fastnode
execjs-fastnodeでは通常のNode.jsを利用したexecjsランタイムと異なり、全ての実行において単一のNode.jsプロセスを利用します。
Node.jsにはVMというAPIがあり、execjsのcontextに相当するものを簡単に実現できるようになっています。
execjs-fastnodeではこれを利用し、Ruby側からの指示でcontextの作成、削除、context上での実行を行うJavaScriptプログラムを単一のNode.jsプロセス上で実行させることでExecJS.compile
に渡されたコードを一度しか評価しないランタイムの実装を実現しています。
さて、execjsのランタイムをexecjs-fastnodeに差し替えることでSprocketsでのアセットコンパイルは高速になるでしょうか。
試したところ、これは期待したほどではありませんでした。
execjs-fastnodeの実装を調べたところ、その原因はexecjs-fastnodeの設計が並列性能を意識していない点にあるようでした。
わかりやすいのはexecjs-fastnode内のこの部分のコードで、これはRubyがNode.jsプロセスとやりとりを行う際に必ず通るパスのコードなのですが、mutexによる同期がなされています。
これは、ExecJS.compile
などが同時に呼ばれた場合でもNode.jsプロセスを最大で1つのみ起動するために必要な同期処理であったと推測しています。
つまり、同時にNode.jsプロセスとのやりとりができるRubyのスレッドは1であるということになります。
並列処理を前提にシーケンス図を描くとこのような感じになります。
execjs-fastnodeを改変して並列性能を発揮するようにしてもよかったのですが、単一のNode.jsプロセスを利用するという設計を踏襲すると並列実行時の安全性を確保したまま並列性能を発揮させるためにはランタイムを構成するコードの大部分に手を入れる必要がありました。 そのため、execjs-fastnodeの改変を諦め、新規に高性能なexecjsランタイムをひとつ作る選択肢を選びました。
execjs-pcruntime
このような経緯で開発したexecjsランタイムがexecjs-pcruntimeです。
このランタイムでは、Node.jsプロセスをcontext毎に1つ用意します。
ExecJS.compile
に渡されるソースが一度だけ評価されるという前提を守りつつRubyのレイヤーでの同期処理無しに並列実行時の安全性をシンプルに保証することができました。
本番環境への導入
このようにして高性能なexecjsランタイムが開発されたわけですが、目標であったCI上での利用をするためにはまだやることがあります。 CI環境でもきちんと性能が向上していることと、ランタイムを切り替えても問題がないことの確認です。 性能改善のために作ったランタイムで逆に性能が悪化していたり、ランタイムを切り替えたことでコンパイルしたアセットが壊れるようなことが本番環境であってはならないからです。 このうち前者に関してはCIのログを見れば確認できるので特別なことをする必要はありません。 問題は後者で、並列性能を発揮するよう設計したexecjs-pcruntimeは当然並列動作させた時に正常に動作することを確認する必要がありますが、これを厳密にやるのは難しいです。 そのため、ランタイムの切り替え前後でコンパイル結果のファイルに差分が無いことをもって正常に動作していることの確認としました。 幸いにもコンパイル対象のファイル数がかなり多いことと開発に利用しているmacbookでは10コア程度の並列性を利用できるので、何度かコンパイルをしてファイル差分が一度も問題が出なければ並列実行に起因する問題は無いことをほぼ期待できます。 これに加え、実際にCI上でコンパイルされたアセットを利用したステージング環境で不具合が見当たらないことを確認して本番環境へマージされました。 本記事執筆時点でマージから1週間ほど経っておりCI上ではexecjs-pcruntimeを利用したアセットコンパイルもかなりの回数行われていますが、これに起因するような問題は見ておらずひとまず正常に動作しているようで一安心です。
まとめ
execjs-pcruntimeを利用するようにすることで、STORESのCI環境において従来420秒程度掛かっていたアセットのコンパイル処理が80秒短縮され、340秒程で完了するようになりました。 コンパイル時間を20%削減する程度の改善に成功していますが、改善のアイデアはまだ残っているので、更なる性能改善にもチャレンジしたいと考えています。