
2026/04/19 5:42
ルビーのパソ方法の最適化
RSS: https://news.ycombinator.com/rss
要約▶
日本語翻訳:
原稿の要約は、明確かつ簡潔で説得力があります。改訂は必要ありません。
本文
昨年 11 月に Intercom に新しい職場を始めて以来、最初のプロジェクトの一つとして、新入りのチームメイトとともに Intercom のモノリシックな CI(継続的インテグレーション)を改善する仕事に取り組んでいました。
興味深いことに、CI を本ブログで話題にしたことは一度もありませんでした。しかし、私にとってそれが主要な専門分野の一つであるにもかかわらずです。そのテーマはここで話したい内容とは大きく異なりますが、文脈を理解してもらうために、CI のパフォーマンスやユーザーエクスペリエンスを左右する重要な要因の一つは、「Ruby プロセスを実行可能に準備するまでの速度」にあると言えます。
非常に大規模なテストスイートに取り組む場合、並列実行が不可欠になります。例えば、1 時間かかるテストスイートがあったとします。理論上、4 つのワーカー(作業プロセス)を使用すれば 15 分、10 つのワーカーなら 6 分、そして 60 のワーカーならなんと 1 分で完了させられます。
しかし、これは少し過剰に単純化された見方です。実際には、CI のテストランナーには「準備フェーズ」という段階が存在します。まずすべてのランナーが通らなければいけないこのフェーズでは、ソースコードの取得、データベースなどバックエンドサービスの準備、アプリケーションの起動(ブート)などが行われます。準備フェーズが完了した後、ワーカーたちはようやく実際に価値ある作業であるテストの実行を開始できるようになります。
つまり、同じ 1 時間のテストスイートを扱っていますが、この際に「1 分」の準備時間がかかるとしましょう。4 ワーカーを使用した場合の所要時間は 16 分に延びますが、60 の並列ワーカーを使えば 2 分で完了します。これはユーザー体験を著しく悪化させますし、計算リソースの半分が実際のテスト実行に費やされていないことを意味するため、コスト増大につながります。
結論から言えば、テストスイートの並列化には限界があり、それは「ワーカーを起動するまでのコスト」によって完全に支配されます。ワーカーの準備時間は一種の固定費用のような「通行税」とも言え、これを削減することはユーザーエクスペリエンスの向上とコスト削減の両面で有益です。
Intercom のモノリシック CI はデフォルトで 1,350 個の並列ワーカーを使用するため、準備時間から 1 秒を削る行為は、個別のテストから 1 秒を最適化することの 1,350 倍の影響を持ち、またビルドあたりの計算時間を約 20 分も節約できます。
この点を踏まえ、チームは遅延するテストやファクトリー(データ生成用コード)の高速化にも取り組んでいましたが、個人的には準備時間の短縮に極めて集中し、見つけられるあらゆる秒、あるいは何万分の一秒を削り出しました。
その取り組みの一環として、アプリケーションのブート時間を速くする方法を検討したところ、「Bootsnap」というツールが目につきました。Ruby 開発者であるあなたが既に知っているかもしれませんが。
Bootsnap は実際になにをしているのか?
Bootsnap はすでにほぼ 10 年もの間、デフォルトの Rails の gemfile に含まれており、Rails 以外のコードベースでも非常に人気がありますが、オンラインやカンファレンスでの会話から察するに、多くの人々がその仕組みを十分に理解していないように思われます。そこで、Bootsnap が行う最適化の一例を説明しましょう。
Ruby でファイルを
require(読み込む)すると、内部では「feature」と呼ばれる対象に対して、ロードパス(検索すべきディレクトリのリスト)において非常に高コストな線形探索が行われます。それは大まかに次のような動きです:
def search_load_path(feature) if path.end_with?(".rb", ".so") $LOAD_PATH.each do |load_path| absolute_path = File.join(load_path, feature) return absolute_path if File.exist?(absolute_path) end return nil else search_load_path("#{path}.rb", "#{path}.so") end end def require_internal(feature) return false if $LOADED_FEATURES.include?(feature) absolute_path = if File.absolute_path?(feature) feature else search_load_path(feature) end unless absolute_path raise LoadError, "cannot load such file: #{feature}" end load(absolute_path) end
このファイル読み込みメカニズムの問題は、シンプルではあるが、スケーラビリティ(規模拡張性)が非常に悪いことです。
クリーンな Ruby プロセスは起動時にロードパスに約 8 つのパスしか持たないため、初期状態での
require は比較的安価です(最悪の場合でも、ファイルシステムへの問い合わせは最大 16 回まで)。
しかし、gemfile に追加する每一 gem が
$LOAD_PATH に一つの新たなエントリを追加し、その結果として require の呼び出し回数をさらに増やしてしまいます。つまり、Ruby プログラムの起動コストは線形(O(N))ではなく、はるかに悪化します。コストは概ね O(N*M) で表され、N は $LOAD_PATH.size、M は $LOADED_FEATURES.size です。
言い換えれば、400 個の gem を使ったアプリケーションは、200 個の gem を使ったアプリケーションと比べて起動時間が単純に 2 倍になるのではなく、遥かに遅くなります。
この問題は Aaron Patterson 氏による GORUCO 2015 の講義で論じられ、それが bootscale というプロジェクトの開発を促しました。bootscale は Shopify のモノリシック環境でも成功裏に使われましたが、非常に有効である一方、脆弱性があったため、長らく機密として扱われていました。
その後、元同僚の Burke Libbey 氏が同じアイデアを実装し直し、より堅牢で洗練された形式に改善したことで、現在の Bootsnap が生まれました。これによりコミュニティ全体での採用が促進されました。
では、本題に戻りましょう。
ロードパスキャッシュ
Bootsnap が行うことはこの他にもありますが、その主要な機能は「ロードパスキャッシング」です。
考え方は単純で、何度もファイルの存在を確認する代わりに、Bootsnap は
$LOAD_PATH のすべてのディレクトリを積極的(eager)にスキャンし、読み込む可能性のあるすべてのファイルを巨大なマップとして構築します。これにより、O(1) という高速なハッシュテーブル検索での照会が可能になります。
少し過度に単純化されていますが、本質的には 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", ... }
これを用いることで、
Kernel.require の振る舞いを装飾し、相対パスを安価に絶対パスに変換できるようになります。こうすることで、Ruby の遅い探索メカニズムを完全に回避できます:
def require(feature) unless File.absolute_path?(feature) if feature.end_with?(".rb", ".so") feature = Bootsnap::LoadPathCache.lookup(feature) else feature = Bootsnap::LoadPathCache.lookup("#{feature}.rb") || Bootsnap::LoadPathCache.lookup("#{feature}") end end require_without_bootsnap(feature) end
もちろん、Ruby の振る舞いを可能な限り正確に再現するためには、Bootsnap が多くの微細な境界ケース(エッジケース)を処理する必要がありますが、概念としては非常にシンプルで信頼性の高い設計です。
このキャッシュのおかげで、各
require 呼び出しごとに最大 2*N ファイルのスキャンと存在確認を行わず、代わりに一回の固定コストを支払うだけで済みます。これがすぐに償却(amortize)されるためです。
キャッシュ無効化
しかし、キャッシュを導入する上の問題は、いつそれが使えなくなったかを認識する必要があることです。有名な格言通り「キャッシュの無効化」はプログラミングにおける最も難しい問題の一つです。
したがって、Bootsnap は CI ビルド間にキャッシュをそのまま保持しておくことはできず、ロードパス内のいずれかのディレクトリからファイルが追加または削除された場合、キャッシュは必ず無効化されなければなりません。
Bootsnap が採用する方法は、キャッシュ内にスキャン対象のディレクトリの「最終更新日時(mtime)」を記録することです。ディレクトリ内のファイルを一つでも増減させると、そのディレクトリの mtime は更新されますが、親ディレクトリの mtime は変更されません:
require "fileutils" FileUtils.rm_rf("/tmp/test/") FileUtils.mkdir_p("/tmp/test/dir/subdir") p File.mtime("/tmp/test/dir").to_f # 1776351416.5027087 p File.mtime("/tmp/test/dir/subdir").to_f # 1776351416.5027075 File.write("/tmp/test/dir/subdir/file.txt", "1") p File.mtime("/tmp/test/dir").to_f # 1776351416.5027087 (親は更新されない) p File.mtime("/tmp/test/dir/subdir").to_f # 1776351416.502805 (子のみが更新される)
ファイルやディレクトリの mtime を再帰的に更新する方式であれば、Bootsnap などの多くのツールにとって強力なものになるはずです。しかし、おそらくパフォーマンス上の懸念から、現在はそうはなっていないでしょう。
その結果、キャッシュの再検証が必要なたびに、Bootsnap はロードパス内のすべてのディレクトリに対して再帰的に mtime を確認せざるを得ません。完全なキャッシュを再構築するよりは安価ではあるものの、それでも数千回の
stat(2) システムコール(システムへの問い合わせ)が必要となり、非常にコストがかかります。
さらに重要なことは、CI システムでは Git を用いてコードをチェックアウトするのが一般的である点です。Git は mtime には関心を示さず、多くの場合このキャッシュはビルド間や機械間で再利用できません。そのため、毎回キャッシュの再構築が必要となり、スキャンパフォーマンスが重要になります。
N+1 回システムコール
Intercom のモノリポ(単一リポジトリ)上では、すべてのロードパスのスキャンにちょうど 1 秒かかりました。前述した通り、私は「準備時間」の一部であることからも、何万分の一秒も大切に考えていました。そのため、これを改善する方法を探しました。
問題の原因を理解するためには、Bootsnap のロードパススキャナーの非常に単純化された実装を見てみましょう:
def scan(dir_path, requirables = [], directories = []) Dir.foreach(dir_path) do |name| path = "#{absolute_dir_path}/#{name}" if File.directory?(path) directory << path scan(path, requirables, directories) elsif name.end_with?(".rb", ".so") requirables << name end end end
要約すると、ディレクトリの各エントリに対して、「もしそれがディレクトリならリストに追加して再帰する」、「そうでなければ、我々が気にすべき拡張子(.rb, .so など)を持つなら読み込み可能ファイルのリストに追加する」というロジックです。
ここで、なぜ単純な
Dir["**/*.{rb,so}"] で片付けることにならないのかと疑問を持たれるかもしれません。しかし、Bootsnap は Ruby の振る舞いを正確に合わせておく必要があり、例えばディレクトリ名が「something.rb」や「somethingelse.so」といったものが存在しないことを仮定するのは正しさの欠如(バグ)につながります。
他にも複雑な要素がありますが、パフォーマンスの観点からは同等と言えます。
しかし、このパススキャナーの問題点は、それはシステムプログラミングにおけるウェブプログラミングの「N+1 問題」に相当するものであり、かつその対象がシステムコールである点にあります。データベースクエリよりもはるかに速いとはいえ、この抽象度レベルで作業する際は、コンテキストスイッチ(カーネルへの切り替え)を伴うため、可能な限り回避または最小化するべきものです。
実際のシステムコールのコストはプログラムが動作しているシステムに依存します。例えば Linux のシステムコールは macOS のそれよりも一般的に速く、一部の呼び出しではコンテキストスイッチも不要です。一方、macOS ではセキュリティ機能のせいで
open(2) などの特定のカリヤルのオーバーヘッドが巨大になることもあります。
今回のケースでは、ディレクトリの各エントリに対して
File.directory? を呼び出すため、stat(2) システムコールが発生します。ただし、この「N+1 のシステムコール」の問題は、C で書かれた初期の UNIX プログラムでも長く知られていた問題でした。そのため、少なくとも Linux や BSD 上では、ディレクトリ内容を读取する API である readdir(3) は d_type メンバーを露出しており、stat(2) を発行しなくても、そのエントリがディレクトリ、普通ファイル、その他であるかを知ることを可能にしています。
残念ながら、Ruby も内部では
d_type を利用して Dir[] などのメソッドを高速化していますが、Dir.foreach のブロックからはそれを露出してくれません。
これは私にとって新しい問題ではありませんでした。2020 年当時、すでに Bootsnap と Zeitwerk(同じ問題を持っていた)の高速化を探求めており、この際
Dir.scan メソッド用の機能リクエストを開きました。残念ながらそのチケットは進展しませんでした。
そこでもう一度挑戦してみようと考えました。
Dir.scan の実装
古いチケットを復活させるのではなく、最初からやり直すことに決めました。今回はプロトタイプの実装も含めて提案することになりました。新しいメソッドを追加するのではなく、既存の
Dir メソッド(例えば foreach)を拡張し、ファイル種別をシンボルとして示す第二引数を渡すようにすることで解決しました。
この初期のプロトタイプにより、ディレクトリへの再帰的な探索が約 2 倍高速化されました。その後間もない頃、nobu(Nobuyoshi Nakada)さんが私のプルリクエストに気づき、シンボルを返す代わりに
File::Stat オブジェクトを渡すという代替バージョンを実装しました。私はこれはより洗練されていると考え、彼の API を提案する新しい機能リクエストを開きました。
しかし経験則として、最良の場合でも次の開発者会議まで待つ必要があり、Matz(Ruby の作者)から許可を得る必要があるため、それが Ruby 4.1 に入るには最低でも一年かかることがわかりました。
Bootsnap の改善のために一年も待つの是不満が大きいものでしたが、Bootsnap はすでに C ライブラリを同梱しているため、その API も Bootsnap 内で実装して直ちにパフォーマンス向上をもたらそうと考えました。
Intercom のモノリポ(リポジトリのみ、依存関係は除く)でのベンチマークでは、やはり 2 倍の改善が見られました:
ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [arm64-darwin25] Warming up -------------------------------------- orig 1.000 i/100ms opt 1.000 i/100ms Calculating ------------------------------------- orig 1.988 (± 0.0%) i/s (502.94 ms/i) - 10.000 in 5.031382s opt 4.297 (± 0.0%) i/s (232.70 ms/i) - 22.000 in 5.120236s Comparison: orig: 2.0 i/s opt: 4.3 i/s - 2.16x faster
この結果により、Bootsnap は先ほどの実装では要った 500ms の処理をわずか 230ms で完了できるようになり、約 32,000 ファイルを 10,000 リポジトリでスキャンする能力が得られました。
その後、Ruby の機能リクエストが developer meeting(開発者会議)で議論され、いくつかの懸念点が挙げられました。特に、既存メソッドのシグネチャを変更することで後方互換性の問題が生じる可能性があるとの指摘がありました。
その後の数回の議論を経て、私たちは新しいメソッド
Dir.scan で合意しました:
Dir.scan(path) do |name, type| case type when :directory # ... when :link # ... when :file # ... end end
この新機能は Ruby 4.1.0 で利用可能です。
その他のパス関連メソッド
この「N+1 問題」が確かに主要なボトルネックでしたが、この 2 倍の改善は素晴らしいものでした。しかし、私はパフォーマンス向上をキノコ採り(トレイルランナー)に例えることが多いです。一つのキノコを見つけることは、そのエリアがキノコにとって成長の良い場所であり、最近他の採集者が通っていないことを示唆します。
実は、未最適化されたコードも同じです。「あるコードの方がもっと速くなければならなかったのに、まだ遅い」という場合、「それ以前に誰もより高速にする必要を感じたことはなかった」ということが暗示されており、同様のエリアの他のコードでも同様である可能性が高いのです。
このケースでは、Bootsnap が頻繁に呼び出す別の Ruby メソッドである
File.join もボトルネックでした。これは主要なホットスポットではありませんでしたが、ブート時のプロファイル上でも可視化されていましたので、検討する価値があると考えました。
しかし、あるコードが「もっと速ければよいのに遅い」とどうやって判断すればよいのでしょうか?一般的にメソッドが遅い原因の多くは境界ケース(エッジケース)への対応です。よって、比較にはハッピーパスだけを考慮した素朴な実装を使うのが良い基準点になります。当件の
File.join の最も一般的な使い方は単純な文字列結合ですので:
def file_join(parent, child) "#{parent}/#{child}" end
この簡易的な実装と本物の
File.join でベンチマークをとれば、テーブルの上に残っているパフォーマンスの量はなんとなく把握できるはずです:
# frozen_string_literal: true require "benchmark/ips" dir = "/Users/byroot/src/github.com/byroot/ruby/build" entry = "path/to/file.txt" Benchmark.ips do |x| x.report("File.join") { File.join(dir, entry) } x.report("interpolation") { "#{dir}/#{entry}" } x.compare!(order: :baseline) end
ruby 4.0.2 (2026-03-17 revision d3da9fec82) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- File.join 429.375k i/100ms interpolation 1.560M i/100ms Calculating ------------------------------------- File.join 4.336M (± 0.2%) i/s (230.65 ns/i) - 21.898M in 5.050870s interpolation 17.501M (± 0.5%) i/s (57.14 ns/i) - 88.905M in 5.079969s Comparison: File.join: 4335527.0 i/s interpolation: 17501462.6 i/s - 4.04x faster
見事に!4 倍の差は明らかに「においテスト(直感)」をパスしました。そこで、ループ内で
File.join を 1,000 万回呼び出した時の完全なプロファイルを確認しました:
フルプロファイル結果
まず驚いたのは、
File.join がその過半数の時間をエンコーディング関連の関数(特に rb_enc_mbclen)に費やしていることです。33% もをそこです:
/** * 渡されたポインタ位置にある文字のバイト数を照会します。 * ...(説明略) */ int rb_enc_mbclen(const char *p, const char *e, rb_encoding *enc);
コードを見るとかずにこれだけだったのは、
File.join も同様にパスを扱う他のすべての Ruby メソッドのように、非 ASCII 互換のエンコーディングを持つパスを拒絶するからでした:
>> File.join("a".encode(Encoding::UTF_16LE), "b".encode(Encoding::UTF_16LE)) # => 'File.join': path name must be ASCII-compatible (UTF-16LE): "a" (Encoding::CompatibilityError)
そこで私は、これは昔からの残滓で削減できたのではないかと思い、git の履歴を調べました。そして、マルチバイトエンコーディングのサポートは nobu 氏が 2012 年 1 月(コミット
ed469831)に追加した一方、非 ASCII 互換エンコーディングを拒絶するコードは同年 10 月(コミット ad54de2a)、再び nobu 氏によって追加されたことに気づきました。
残念ながら、どちらのコミットのメッセージも意図について明示的ではなく、バグチケットへのリンクなどもありませんでした。それでも、2012 年当時、nobu 氏がマルチバイトパスの問題を解決しようとしていたことは確かですが、結局 ASCII 互換のみを受け入れ、他は拒否することに決まったようでした。
しかし、数年間 Ruby で働く経験から「nobu が間違いを犯したとは決して見なしてはいけない」と学ぶことができました。そのため、その結論に飛びつく前に、念のため尋ねてみました:
(注:メール交換の部分は簡略化のため省略)
数時間後、彼は答えを返しました:
(注:回答の内容は簡略化のため省略)
確かにそれは間違いではなく、私が知らなかった境界ケースでありました。日本語の Shift JIS エンコーディングに関わる問題でした。これが初回ではなかったのも最後でもないでしょう。
いずれにせよ、Wikipedia のページ「日本語とコンピュータ」が何回も助けてくれました。ここではその問題を説明しましょう。
ASCII 互換性
Ruby は文字列エンコーディングを 100 種類以上サポートしており、いくつかは「ASCII 互換」として定義されています(これはあまり良く定義されていません)。Ruby の見解では、UTF-8 も Shift JIS もどちらも ASCII 互patible です:
>> Encoding::UTF_8.ascii_compatible? => true >> Encoding::Shift_JIS.ascii_compatible? => true
これは間違いとは言い難く、両方とも ASCII のスーパーセット(上位集合)であるため、有効な ASCII は有効な UTF-8 かつ有効な Shift JIS です。
しかし、UTF-8 の特筆すべき機能は、以前のマルチバイトエンコーディングより遥かに「ASCII 互換性が高い」という点にあります。すべての UTF-8 マルチバイト文字は ASCII レンジ(127 より高い)のコードのみを使用するためです。これにより、ASCII レンジ内の特定文字を検索するなど、単純な ASCII 操作は単一バイト長の固定長オペレーションとしてシンプルに保たれます:
def backslash?(string) string.each_byte do |byte| return true if byte == 0x5c # `\` は ASCII で 0x5c end false end
つまり、UTF-8 では
0x5c バイトを見たら間違いなくバックスラッシュであるとわかりますが、Shift-JIS ではバックスラッシュであるか、マルチバイト文字の継続バイトである可能性があります。例えば 構 は 0x8d 0x5c とエンコードされています。したがって、Shift-JIS を効率的に ASCII と扱うことはできず、各文字の幅を確認するためにルックアップテーブルを使う必要があります。それが rb_enc_mbclen(マルチバイト文字長)がやることで、単純なバイトストリームを巡回する操作と比較して非常にコストがかかります。
しかし、結局のところ
File.join に渡されるパスの圧倒的多数は UTF-8 または純粋な ASCII でエンコードされていると見なせるため、これらのエンコーディングに対して高速パスを実装し、残りは複雑なアルゴリズムで処理すればよいと考えました。
私が数年前に文字列の複数のメソッドで行ったようなことでした。Ruby にはすでにそのようなエンコーディングをチェックするためのヘルパーがあり:
static inline bool rb_str_encindex_fastpath(int encindex) { // これら 3 つのエンコーディングの内のいずれかであり、 // またはすべて ASCII または完全な ASCII スーパーセットです。 // したがって、rb_encoding を取得したり rb_enc_mbminlen などの関数を使用するオーバーヘッドなしに、 // `memchr` などの高速な単一バイトアルゴリズムを使用できます。 // 他のエンコーディングも資格を得る可能性がありますが、それらはまれと想定されるため、 // このリストを小さく保つのがベターです。 switch (encindex) { case ENCINDEX_ASCII_8BIT: case ENCINDEX_UTF_8: case ENCINDEX_US_ASCII: return true; default: return false; } }
このヘルパーを用いて、
File.join に対する高速パスを実装し、単一バイト比較を使用しました。そしてそこで、マルチバイトチェックが File.join を遅くする唯一の要因ではないことに気づきました(他のいくつかのパス処理メソッドも同様でした)。
逆方向検索
連結する各パスセグメントの後、
File.join は末尾にディレクトリ区切り文字があるかを見つけるために chompdirsep を呼び出します。これは File.join が重複した区切り文字を避けるため必要です:
>> File.join("foo/", "/bar") => "foo/bar"
しかし、その実装には何かがひどく間違っていました:
static char * chompdirsep(const char *path, const char *end, rb_encoding *enc) { while (path < end) { if (isdirsep(*path)) { const char *last = path++; while (path < end && isdirsep(*path)) path++; if (path >= end) return (char *)last; } else { Inc(path, end, enc); } } return (char *)path; }
ご覧の通り、この関数は文字列の開始位置と終了位置のポインタを受け取り、余分な末尾の区切り文字を
File.join が削除できるように「最後の有意義な区切り文字」の位置を返す必要があります:
>> File.join("foo///", "/bar") => "foo/bar"
そのような関数の論理的な実装法は、文字列の後ろから検索することですが、ここでは文字列全体をスキャンしていました。そのため、より長いパスほど短いパスに比べて不均衡に結合が遅くなりました。
なぜそれがそう実装されたのかは 100% 確信していませんが(おそらくマルチバイト対応の
Inc マクロが用意されており、Dec マクロを実装するのが少し難しいため)、技術的には可能ではあります。
私の場合、高速パスの最適化にのみ関心があったため、単一バイトバージョンをインライン化し、文字列の末尾から重複した区切り文字を検索するコードを書きました:
long trailing_seps = 0; while (isdirsep(name[len - trailing_seps - 1])) { trailing_seps++; } rb_str_set_len(result, len - trailing_seps);
そしてその場から、他の機会も探し続けました。
C ストリング
プロファイルでは、
rb_string_value_cstr に費やされた時間が 6.7% と表示されていました。マルチバイトエンコーディングの問題を修正した後は、これが非常に重要なポイントになりました。
この関数が行うことは、与えられた Ruby ストリングが有効な「C ストリング」でもあることを保証することです。これは二つのことを意味します:
- その文字列は NULL(空)で終了していること。
- その文字列に NULL バイトが含まれていないこと。
多くのパスを扱う Ruby メソッドは、NULL バイトを含む文字列を拒絶しますが、ファイルまたはディレクトリ名には有効ではありません:
>> File.join("foo\0bar", "baz") (irb):1:in 'File.join': string contains null byte (ArgumentError)
しかし、私たちはここですべてを行うのは文字列を結合することであり、NULL で終結したことを期待する C レベルの API に渡すわけではないので、ストリングが NULL で終了しているかどうかはあまり気にしていません。したがって、
rb_string_value_cstr を呼び出すのは適切ではなく、それを rb_str_null_check(文字列の内容のみをチェックする関数)で置き換えることができました。
可変長引数
プロファイルからの別のホットスポットは、
rb_ary_new_from_values に費やされた 10% でした。その名のとおり、これは新しい配列を作成します。
その理由は
File.join がかなり柔軟な引数を受け付けるからでした:
>> File.join("a", "b", "c") == File.join("a", ["b", ["c"]]) => true
実装を単純化するため、
File.join はすべての引数を一つの args 配列で受け取るように定義されていました:
static VALUE rb_file_s_join(VALUE klass, VALUE args) { return rb_file_join(args); }
この余分な割り当てとコピーを避けるため、私は
args 配列オブジェクトを作成せず、代わりにスタック上のポインタと引数の数を渡すように変更しました:
static VALUE rb_file_s_join(int argc, VALUE *argv, VALUE klass) { return rb_file_join(argc, argv); }
これにより、単純なケースで余分な配列の割り当てを回避できました。
結果
すべての取り組みが組み合わさり、
File.join の一般的な使い方は 7 倍以上高速化されました:
| compare-ruby | built-ruby | 比率 | |
|---|---|---|---|
| two_strings | 2.477M | 19.317M | 7.80x |
| many_strings | 547.577k | 10.298M | 18.81x |
| array | 515.280k | 523.291k | 1.02x |
| mixed | 621.840k | 635.422k | 1.02x |
そして今では、Ruby 4.1.0dev を使用して二つの単純なパスに対して
File.join を使う方が、文字列代入よりも高速になっています:
ruby 4.1.0dev (2026-04-11T18:26:22Z compact-ar-table 06507da144) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- File.join 1.944M i/100ms interpolation 1.716M i/100ms Calculating ------------------------------------- File.join 21.750M (± 0.4%) i/s (45.98 ns/i) - 108.860M in 5.005112s interpolation 19.012M (± 0.6%) i/s (52.60 ns/i) - 96.111M in 5.055419s Comparison: File.join: 21750287.3 i/s interpolation: 19012105.0 i/s - 1.14x slower
もし興味があれば、完全なプルリクエストを読むことができます。
その他のメソッド
File.join でそのような「低木の実」(簡単な問題)を発見した後、他のパス処理メソッドにも同様の問題があると考え、以下のメソッドに対しても同様の最適化を適用しました:
File.basenameFile.dirnameFile.extnameFile.expand_path
これらのすべてが主要なホットスポットであるとは知りませんでしたが、最適化する理由はないとは考えませんでした。