Bootsnap
Bootsnap は RubyVM におけるバイトコード生成やファイルルックアップ等の時間のかかる処理を最適化するためのライブラリです。ActiveSupport や YAML もサポートしています。内部動作もご覧ください。
注意書き: このライブラリは英語話者によって管理されています。この README は日本語ですが、日本語でのサポートはしておらず、リクエストにお答えすることもできません。バイリンガルの方がサポートをサポートしてくださる場合はお知らせください!:)
パフォーマンス
- Discourse では、約6秒から3秒まで、約50%の起動時間短縮が確認されています。
- 小さなアプリケーションでも、50%の改善(3.6秒から1.8秒)が確認されています。
- 非常に巨大でモノリシックなアプリである Shopify のプラットフォームでは、約25秒から6.5秒へと約75%短縮されました。
使用方法
この gem は macOS と Linux で作動します。まずは、bootsnap
を Gemfile
に追加します:
gem 'bootsnap', require: false
Rails を使用している場合は、以下のコードを、config/boot.rb
内にある require 'bundler/setup'
の直後に追加してください。
require 'bootsnap/setup'
単に gem 'bootsnap', require: 'bootsnap/setup'
と指定することも技術的には可能ですが、最大限のパフォーマンス改善を得るためには Bootsnap をできるだけ早く読み込むことが重要です。
この require の仕組みはこちらで確認できます。
Rails を使用していない場合、または、より多くの設定を変更したい場合は、以下のコードを require 'bundler/setup'
の直後に追加してください(早く読み込まれるほど、より多くのものを最適化することができます)。
require 'bootsnap' env = ENV['RAILS_ENV'] || "development" Bootsnap.setup( cache_dir: 'tmp/cache', # キャッシュファイルを保存する path development_mode: env == 'development', # 現在の作業環境、例えば RACK_ENV, RAILS_ENV など。 load_path_cache: true, # キャッシュで LOAD_PATH を最適化する。 autoload_paths_cache: true, # キャッシュで ActiveSupport による autoload を行う。 disable_trace: true, # (アルファ) `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }`をセットする。 compile_cache_iseq: true, # ISeq キャッシュをコンパイルする compile_cache_yaml: true # YAML キャッシュをコンパイルする )
ヒント: require 'bootsnap'
を BootLib::Require.from_gem('bootsnap', 'bootsnap')
で、 こちらのトリックを使って置き換えることができます。こうすると、巨大な$LOAD_PATH
がある場合でも、起動時間を最短化するのに役立ちます。
注意: Bootsnap と Spring は別領域の問題を扱うツールです。Bootsnap は個々のソースファイルの読み込みを高速化します。一方で、Spring は起動されたRailsプロセスのコピーを保持して次回の起動時に起動プロセスの一部を完全にスキップします。2つのツールはうまく連携しており、どちらも新しく生成された Rails アプリケーションにデフォルトで含まれています。
環境
Bootsnapのすべての機能はセットアップ時の設定に従って開発、テスト、プロダクション、および他のすべての環境で有効化されます。Shopify では、この gem を問題なくすべての環境で安全に使用しています。
特定の環境で機能を無効にする場合は、必要に応じて適切な ENV 変数または設定を考慮して設定を変更することをおすすめします。
内部動作
Bootsnap は、処理に時間のかかるメソッドの結果をキャッシュすることで最適化しています。これは、大きく分けて2つのカテゴリに分けられます。
- Path Pre-Scanning
Kernel#require
とKernel#load
を$LOAD_PATH
フルスキャンを行わないように変更します。ActiveSupport::Dependencies.{autoloadable_module?,load_missing_constant,depend_on}
をActiveSupport::Dependencies.autoload_paths
のフルスキャンを行わないようにオーバーライドします。
- Compilation caching
- Ruby バイトコードのコンパイル結果をキャッシュするためのメソッド
RubyVM::InstructionSequence.load_iseq
が実装されています。 YAML.load_file
を YAML オブジェクトのロード結果を MessagePack でキャッシュするように変更します。 MessagePack でサポートされていないタイプが使われている場合は Marshal が使われます。
- Ruby バイトコードのコンパイル結果をキャッシュするためのメソッド
Path Pre-Scanning
(このライブラリは bootscale という別のライブラリを元に開発されました)
Bootsnap の初期化時、あるいはパス(例えば、$LOAD_PATH
)の変更時に、Bootsnap::LoadPathCache
がキャッシュから必要なエントリーのリストを読み込みます。または、必要に応じてフルスキャンを実行し結果をキャッシュします。
その後、たとえば require 'foo'
を評価する場合, Ruby は $LOAD_PATH
['x', 'y', ...]
のすべてのエントリーを繰り返し評価することで x/foo.rb
, y/foo.rb
などを探索します。これに対して Bootsnap は、キャッシュされた require 可能なファイルと $LOAD_PATH
を見ることで、Rubyが最終的に選択するであろうパスで置き換えます。
この動作によって生成された syscall を見ると、最終的な結果は以前なら次のようになります。
open x/foo.rb # (fail) # (imagine this with 500 $LOAD_PATH entries instead of two) open y/foo.rb # (success) close y/foo.rb open y/foo.rb ...
これが、次のようになります:
open y/foo.rb ...
autoload_paths_cache
オプションが Bootsnap.setup
に与えられている場合、ActiveSupport::Dependencies.autoload_paths
をトラバースする方法にはまったく同じ最適化が使用されます。
*_path_cache
を機能させるオーバーライドを図にすると、次のようになります。
Bootsnap は、 $LOAD_PATH
エントリを安定エントリと不安定エントリの2つのカテゴリに分類します。不安定エントリはアプリケーションが起動するたびにスキャンされ、そのキャッシュは30秒間だけ有効になります。安定エントリーに期限切れはありません。コンテンツがスキャンされると、決して変更されないものとみなされます。
安定していると考えられる唯一のディレクトリは、Rubyのインストールプレフィックス (RbConfig::CONFIG['prefix']
, または /usr/local/ruby
や ~/.rubies/x.y.z
)下にあるものと、Gem.path
(たとえば ~/.gem/ruby/x.y.z
) や Bundler.bundle_path
下にあるものです。他のすべては不安定エントリと分類されます。
Bootsnap::LoadPathCache::Cache
に加えて次の図では、エントリの解決がどのように機能するかを理解するのに役立つかもしれません。経路探索は以下のようになります。
また、LoadError
のスキャンがどれほど重いかに注意を払うことも大切です。もし Ruby が require 'something'
を評価し、そのファイルが $LOAD_PATH
にない場合は、それを知るために 2 * $LOAD_PATH.length
のファイルシステムアスセスが必要になります。Bootsnap は、ファイルシステムにまったく触れずに LoadError
を投げ、この結果をキャッシュします。
Compilation Caching
(このコンセプトのより分かりやすい解説は yomikomu をお読み下さい。)
Ruby には複雑な文法が実装されており、構文解析は簡単なオペレーションではありません。1.9以降、Ruby は Ruby ソースを内部のバイトコードに変換した後、Ruby VM によって実行してきました。2.3.0 以降、RubyはAPIを公開し、そのバイトコードをキャッシュすることができるようになりました。これにより、同じファイルが複数ロードされた時の、比較的時間のかかる部分をバイパスすることができます。
また、アプリケーションの起動時に YAML ドキュメントの読み込みに多くの時間を費やしていることを発見しました。そして、 MessagePack と Marshal は deserialization にあたって YAML よりもはるかに高速であるということに気付きました。そこで、YAML ドキュメントを、Ruby バイトコードと同じコンパイルキャッシングの最適化を施すことで、高速化しています。Ruby の “バイトコード” フォーマットに相当するものは MessagePack ドキュメント (あるいは、MessagePack をサポートしていないタイプの YAML ドキュメントの場合は、Marshal stream)になります。
これらのコンパイル結果は、入力ファイル(FNV1a-64)のフルパスのハッシュを取って生成されたファイル名で、キャッシュディレクトリに保存されます。
Bootsnap 無しでは、ファイルを require
するために生成された syscall の順序は次のようになっていました:
open /c/foo.rb -> m fstat64 m close m open /c/foo.rb -> o fstat64 o fstat64 o read o read o ... close o
しかし Bootsnap では、次のようになります:
open /c/foo.rb -> n fstat64 n close n open /c/foo.rb -> n fstat64 n open (cache) -> m read m read m close m close n
これは一見劣化していると思われるかもしれませんが、性能に大きな違いがあります。
(両方のリストの最初の3つの syscalls – open
, fstat64
, close
– は本質的に有用ではありません。このRubyパッチは、Boosnap と組み合わせることによって、それらを最適化しています)
Bootsnap は、64バイトのヘッダーとそれに続くキャッシュの内容を含んだキャッシュファイルを書き込みます。ヘッダーは、次のいくつかのフィールドで構成されるキャッシュキーです。
version
、Bootsnapにハードコードされる基本的なスキーマのバージョンruby_platform
、RUBY_PLATFORM
(x86_64-linux-gnuなど)変数とglibcバージョン(Linuxの場合)またはOSバージョン(BSD、macOSの場合はuname -v
)のハッシュcompile_option
、RubyVM::InstructionSequence.compile_option
の返り値ruby_revision
、コンパイルされたRubyのバージョンsize
、ソースファイルのサイズmtime
、コンパイル時のソースファイルの最終変更タイムスタンプdata_size
、バッファに読み込む必要のあるヘッダーに続くバイト数。
キーが有効な場合、キャッシュがファイルからロードされます。そうでない場合、キャッシュは再生成され、現在のキャッシュを破棄します。
最終的なキャッシュ結果
次のファイル構造があるとします。
/ ├── a ├── b └── c └── foo.rb
そして、このような $LOAD_PATH
があるとします。
["/a", "/b", "/c"]
Bootsnap なしで require 'foo'
を呼び出すと、Ruby は次の順序で syscalls を生成します:
open /a/foo.rb -> -1 open /b/foo.rb -> -1 open /c/foo.rb -> n close n open /c/foo.rb -> m fstat64 m close m open /c/foo.rb -> o fstat64 o fstat64 o read o read o ... close o
しかし Bootsnap では、次のようになります:
open /c/foo.rb -> n fstat64 n close n open /c/foo.rb -> n fstat64 n open (cache) -> m read m read m close m close n
Bootsnap なしで require 'nope'
を呼び出すと、次のようになります:
open /a/nope.rb -> -1 open /b/nope.rb -> -1 open /c/nope.rb -> -1 open /a/nope.bundle -> -1 open /b/nope.bundle -> -1 open /c/nope.bundle -> -1
…そして、Bootsnap で require 'nope'
を呼び出すと、次のようになります…
# (nothing!)