STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

execjsランタイムを開発してCI上のアセットコンパイル時間を20%短縮した話

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のランタイムでは

  1. ExecJS.compileに渡されたコード(例えばCoffeeScriptコンパイラやUglifyJSのコード)をcontext内部に保存しておき
  2. context.callに渡されたコードの前に結合して実行したいコードを生成
  3. Node.jsプロセスを起動してコードを評価
  4. 結果をRuby側で受け取ってパース

というプロセスによりJavaScriptコードの実行を可能にしています。

execjs標準のNode.jsランタイムのシーケンス図
さて、このプロセスには無駄があります。 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-fastnodeのシーケンス図
これは実際パフォーマンス改善にうまくはたらくようで、通常のNode.jsランタイムに比べて20倍高速になるようです(詳細はexecjs-fastnodeのREADME.md参照)。

さて、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をマルチスレッドに利用した場合のシーケンス図
execjs-fastnode検証当時のテスト環境では並列性能を発揮する方向性で(Sprockets内部まで手を入れて)高速化を試みていました。 この思想とexecjs-fastnodeの並列性能を意識しない実装の相性が悪く、結果としてあまり性能向上に繋がらなかったものと考えています。

execjs-fastnodeを改変して並列性能を発揮するようにしてもよかったのですが、単一のNode.jsプロセスを利用するという設計を踏襲すると並列実行時の安全性を確保したまま並列性能を発揮させるためにはランタイムを構成するコードの大部分に手を入れる必要がありました。 そのため、execjs-fastnodeの改変を諦め、新規に高性能なexecjsランタイムをひとつ作る選択肢を選びました。

execjs-pcruntime

このような経緯で開発したexecjsランタイムがexecjs-pcruntimeです。 このランタイムでは、Node.jsプロセスをcontext毎に1つ用意します。

execjs-pcruntimeのシーケンス図
execjs-fastnodeで問題だった同期処理が排除されていることがわかるかと思います。 シーケンス図には表れていませんが、単一のcontextを複数スレッドで共有した場合でもRubyのレイヤーでの同期処理無しに実行ができるようになっています。 これにより、ExecJS.compileに渡されるソースが一度だけ評価されるという前提を守りつつRubyのレイヤーでの同期処理無しに並列実行時の安全性をシンプルに保証することができました。

本番環境への導入

このようにして高性能なexecjsランタイムが開発されたわけですが、目標であったCI上での利用をするためにはまだやることがあります。 CI環境でもきちんと性能が向上していることと、ランタイムを切り替えても問題がないことの確認です。 性能改善のために作ったランタイムで逆に性能が悪化していたり、ランタイムを切り替えたことでコンパイルしたアセットが壊れるようなことが本番環境であってはならないからです。 このうち前者に関してはCIのログを見れば確認できるので特別なことをする必要はありません。 問題は後者で、並列性能を発揮するよう設計したexecjs-pcruntimeは当然並列動作させた時に正常に動作することを確認する必要がありますが、これを厳密にやるのは難しいです。 そのため、ランタイムの切り替え前後でコンパイル結果のファイルに差分が無いことをもって正常に動作していることの確認としました。 幸いにもコンパイル対象のファイル数がかなり多いことと開発に利用しているmacbookでは10コア程度の並列性を利用できるので、何度かコンパイルをしてファイル差分が一度も問題が出なければ並列実行に起因する問題は無いことをほぼ期待できます。 これに加え、実際にCI上でコンパイルされたアセットを利用したステージング環境で不具合が見当たらないことを確認して本番環境へマージされました。 本記事執筆時点でマージから1週間ほど経っておりCI上ではexecjs-pcruntimeを利用したアセットコンパイルもかなりの回数行われていますが、これに起因するような問題は見ておらずひとまず正常に動作しているようで一安心です。

まとめ

execjs-pcruntimeを利用するようにすることで、STORESのCI環境において従来420秒程度掛かっていたアセットのコンパイル処理が80秒短縮され、340秒程で完了するようになりました。 コンパイル時間を20%削減する程度の改善に成功していますが、改善のアイデアはまだ残っているので、更なる性能改善にもチャレンジしたいと考えています。