bootsnap について調べてみた

https://qiita.com/Daniel_Nakano/items/aadeaa7ae4e227b73878 からの転記

これはなに?

今年のRubyKaigi 2017でrailsの起動時間を短縮してくれるbootsnapという便利なgemを知り、これをトレタで開発しているのrailsアプリに導入できないかと思い、このgemの内部処理を調査したメモです。

あ、bootstrapではなくbootsnapです。一応。 日本語版のREADMEはこちら

bootsnapとは?

railsの起動時の処理を最適化する(パスとrubyコンパイル結果をキャッシュ)ことで起動時間を短縮してくれる便利なgemです。

導入方法

Gemfileに

gem 'bootsnap', require: false

を追加して、config/boot.rbrequire 'bundler/setup'の直下に

require 'bootsnap/setup'

を追加するだけです。

また、require 'bootsnap/setup'の代わりに

require 'bootsnap'
env = ENV['RAILS_ENV'] || "development"
Bootsnap.setup(
  cache_dir:            'tmp/cache',          # キャッシュを保存するパス
  development_mode:     env == 'development', # RACK_ENV、RAILS_ENV
  load_path_cache:      true,                 # $LOAD_PATHをキャッシュする
  autoload_paths_cache: true,                 # ActiveSupport::Dependencies.autoload_pathsをキャッシュする
  compile_cache_iseq:   true,                 # rubyの実行結果をキャッシュする
  compile_cache_yaml:   true                  # YAMLのコンパイル結果をキャッシュする
)

のようにして個別の設定を行うこと出来ます。

試してみた

どのぐらい起動時間が短縮するかの実際のrailsアプリで検証してみます。

検証環境

検証方法

rails newrailsアプリを作成し、bootsnapの導入前後で簡単なrails runnerの実行時間を計測します。 また、bootsnap以外による最適化をなるべく避けるためにSpringは無効化しています。

実行するrails runnerのスクリプト

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'

検証

導入前

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.16s
user    1.52s
sys     0.60s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.12s
user    1.51s
sys     0.59s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.15s
user    1.53s
sys     0.60s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.09s
user    1.48s
sys     0.58s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    2.12s
user    1.51s
sys     0.58s

導入後

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.27s
user    0.91s
sys     0.32s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.31s
user    0.94s
sys     0.33s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.22s
user    0.84s
sys     0.28s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.26s
user    0.86s
sys     0.28s

$ time DISABLE_SPRING=1 bundle exec rails runner 'puts 0'
0
real    1.26s
user    0.89s
sys     0.32s

検証結果

導入前後の実行時間の平均から算出したところ、40.6%起動時間が短縮されるという結果がでました。 また、トレタのrailsアプリでも同じように実行した結果、約30%起動時間が短縮されました。

仕組み

bootsnapは大きく分けて2つの最適化の処理をしています。

  • Path Pre-Scanning
  • Compilation caching

基本的な処理方法としては、どちらともキャッシュすることでシステムコールを叩く回数が減らし起動時間を短くしています。

Path Pre-Scanning

$LOAD_PATHActiveSupport::Dependencies.autoload_paths をキャッシュすることで起動の度に実行されるパスのフルスキャンをスキップしています。

$LOAD_PATHのキャッシュ

railsでは起動時にコード中に require 'hoge' があると目的のファイルが見つかるまで $LOAD_PATH をフルスキャンが実行します。 bootsnapを導入することで起動時に事前に $LOAD_PATH をキャッシュしておき、フルスキャンの結果ではなくキャッシュから目的のファイルをロードするようになります。そうすることで、毎回行われるフルスキャンがスキップされ不必要なシステムコールの実行が減り起動時間が短縮されます。 これらの処理は、Kerenelモジュールの require メソッドと load メソッドにオーバライドしてキャッシュを見に行く処理を差し込むことで実現しています。

ActiveSupport::Dependencies.autoload_pathsのキャッシュ

処理方法としては$LOAD_PATH のキャッシュと同様で、事前に ActiveSupport::Dependencies.autoload_paths をキャッシュをし、それを読みに行くようにしています。 この処理は、 ActiveSupport::Dependencies モジュールの autoloadable_module? メソッド、 load_missing_constant メソッド、 depend_on メソッドをオーバーライドしキャッシュされた ActiveSupport::Dependencies.autoload_paths を見るようにして実現しています。

全てのパスを永続的にキャッシュするわけではない

キャッシュする対象となるパスはstableとvolatileの2種類に分けられています。 stableに分類されるパスは、一度スキャンされたら永続的にキャッシュされます。一方、volatileに分類されるパスは、railsの起動の度に新しく作成され30秒後に削除されます。

stableなパスとvolatileなパスの分類

パスの分類については、以下の2つの条件の何れかに合致していればstableに分類します。

  • gemのパス
  • 使用しているバーションのrubyのインストール先の基準ディレクトリのパス( RbConfig::CONFIG['prefix']

異なる場合には全てvolatileに分類されます。

Compilation caching

rubyのコードのコンパイル結果(YARV命令列)とYAMLファイルのコンパイル結果(MessagePackファイル、またはMarshalファイル)をキャッシュすることで、railsの起動時に走るこれらの処理がスキップされ起動時間が短縮されます。

rubyコンパイル結果のキャッシュ

全く知らなかったのですが、ruby2.3.0から導入されたクラスでRubyVM::InstructionSequeというのがあり、rubyのコードのYARV命令列を取得したり、それをバイナリデータに変換したりすることが出来ます。

RubyVM::InstructionSequeを利用してrailsの起動時にrubyのコードをYARV命令列にコンパイルし、その結果をバイナリの変換してtmp/cache配下に保存します。キャッシュするデータの保存場所はデフォルトではconfig/boot.rbになっています。もし変更したい場合は、 Bootsnap.setup(cache_dir: 保存するフォルダ) のようにして指定することができます。

rubyのコードを事前にコンパイルする仕組みについては、Compilation cachingのベースとなっているyomikomu gemるびまのrubyのプレコンパイルの記事に詳しく書かれているので是非読んでみて下さい。

YAMLファイルのコンパイル結果のキャッシュ

railsアプリを書いてると、コードベースが大きくなるに連れYAMLファイルで管理している設定ファイルを増えてくることはよくあると思うのですが、これも起動に時間がかかってしまう大きな原因の一つとなっています。

これを最適化するために、YAMLファイルもrubyのコードの場合と同じように別の形式に変換しキャッシュとして利用することで起動時のYAMLファイルのロードをスキップさせることが可能になります。

処理方法としては、YAMLファイルをMessagePackデータ、もし不可能な場合にはMarshalデータにコンパイルした結果をtmp/cache配下に保存してキャッシュとして利用しています。

キャッシュの再作成

bootsnapでは、キャッシュを作成する際に幾つかのkeyを含んだヘッダを付与します。 railsの起動の際にこのヘッダを見て、有効であればキャッシュされたデータをロードし、有効でなければ再作成をします。

使い所

bootsnapは、元々巨大なモノリシックなrailsアプリの開発の際に起動に時間がかかりすぎるために開発の速度が遅くなってしまうのを解消するために作られたという経緯があります。使い所としては正にここで、development環境での開発時に一番効力を発揮します。個人的には、特にspecを頻繁に回したりするときに起動時間が短くなるので、あのちょっとした待ち時間(あれ、結構いらいらしませんか?)が短縮されるので非常に期待しています。

また、productionの環境で使用する場合は、デプロイの時間が短縮されるので効果的かとは思いますが、まだ実例が少なくキャッシュが原因で何か予期せぬ不具合が発生したりする可能性も考えられます。なので、もし使用する場合は事前にテストをしたりしてよく調査する必要があるでしょう。

所感

Bootsnapを調査する過程でRubyVM::InstructionSequenceの存在や起動時間を短縮するために用いる手法などを知れたのは、個人的には非常に得るものが大きかったです。 これらの調査を元に、実際にトレタでの開発環境にbootsnapを導入してみて、おそらく良い部分や悪い部分が出てくると思うので、その結果をまた共有できたらなと思っています。

参考