4月18日、Jean Boussierが「Optimizing Ruby Path Methods」と題した記事を公開した。この記事では、RubyのBootsnapライブラリの性能最適化とファイルシステム操作の高速化について詳しく紹介されている。
Boussier氏(ShopifyのRubyコアチームメンバー)がIntercomで実施した最適化により、32,000ファイルのスキャン時間が500msから230msへと2.16倍高速化された。1350並列ワーカーを使用する大規模テストスイートでは、この1秒短縮が実質22分の時間節約に相当する。
大規模テストスイートにおける起動時間の重要性
現代のRubyアプリケーション開発では、CI/CDパイプラインでの並列テスト実行が一般的だ。Intercom(アイルランドを拠点とするカスタマーサポートプラットフォーム企業)では1350並列ワーカーでテストを実行するため、各ワーカーのセットアップ時間1秒短縮は、全体で1350秒(22分)の節約となる。
大規模テストスイートを並列実行する場合、ワーカーのセットアップ時間は固定コストとして作用する:
- 4ワーカー:16分(15分テスト + 1分セットアップ)
- 60ワーカー:2分(1分テスト + 1分セットアップ)
並列度が高いほどセットアップ時間の比重が増すため、この部分の最適化が極めて重要となる。
Bootsnapの仕組みとパフォーマンスボトルネック
多くのRuby開発者が使用しているBootsnapは、Shopifyが開発するRuby高速化ライブラリだ。その主要機能はロードパスキャッシングにある。
通常、Rubyのrequireは線形探索を行い、$LOAD_PATH内で該当ファイルを順次探す。400個のgemを持つアプリケーションは、200個の場合の2倍以上起動が遅くなる。これは計算量がO(N×M)(N: ロードパス数、M: ロード済みファイル数)となるためだ。
Bootsnapは事前にロードパス内をスキャンし、以下のようなハッシュマップを構築する:
@cache = {
"active_support/core_ext.rb" => "/gems/activesupport-8.1.2/lib/active_support/core_ext.rb",
"active_support/json.rb" => "/gems/activesupport-8.1.2/lib/active_support/json.rb"
}
これによりrequire時の探索をO(1)のハッシュルックアップに変換できる。
問題の根本原因:N+1システムコール
Boussier氏がIntercomのモノレポで計測したところ、ロードパススキャンに約1秒を要していた。問題の根本原因は、既存の実装がN+1システムコールを発生させることだった。
従来の実装では、各ディレクトリエントリに対してFile.directory?がstat(2)システムコールを発行するため、ファイル数に比例して性能が劣化していた。32,000ファイルがあれば32,000回のシステムコールが発生する計算だ。
解決策:Dir.scanメソッドの導入
LinuxやBSDのreaddir(3) APIはd_typeメンバーを提供し、追加のシステムコールなしでファイルタイプを判定できる。しかしRubyのDir.foreachはこの情報を公開していなかった。
Boussier氏は2020年にDir.scanメソッドの機能要求をRuby公式に提出していたが進展がなかったため、今回プロトタイプ実装を含めて再提案した。
最終的に採用されたAPIはRuby 3.4.0で利用可能となる予定だ:
Dir.scan(path) do |name, type|
case type
when :directory
# 追加のstat()なしでディレクトリと判定
when :file
# 追加のstat()なしでファイルと判定
end
end
この最適化により、Intercomのモノレポ(約32,000ファイル、10,000ディレクトリ)でのスキャン時間が500msから230msへと2.16倍高速化された。
今後の展望
パフォーマンス改善は「きのこ狩り」に似ており、ひとつ見つければ周辺にも改善余地がある。Boussier氏はFile.joinなどの他のパスメソッドも調査し、さらなる最適化を実施している。
この最適化はBootsnapに直接実装されるため、Ruby 3.4.0のリリースを待つことなく恩恵を受けられる。大規模Rubyアプリケーションの起動時間短縮に大きく貢献する改善である。
Jean Boussier氏の他のパフォーマンス改善記事も、Ruby VMやガベージコレクション最適化の観点から参考になるだろう。
詳細はOptimizing Ruby Path Methodsを参照していただきたい。