ルビーのパソ方法の最適化

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-rubybuilt-ruby比率
two_strings2.477M19.317M7.80x
many_strings547.577k10.298M18.81x
array515.280k523.291k1.02x
mixed621.840k635.422k1.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.basename
  • File.dirname
  • File.extname
  • File.expand_path

これらのすべてが主要なホットスポットであるとは知りませんでしたが、最適化する理由はないとは考えませんでした。

同じ日のほかのニュース

一覧に戻る →

2026/04/19 5:54

『好みの色を選べる:NIST の科学家らが、任意の波長のレーザーを開発』

## Japanese Translation: NIST の科学者と協力者が、Scott Papp という NIST の物理学者をリーダーとして発表された「Monolithic 3D integration of tantalum pentoxide nonlinear photonics」という論文に詳述されているように、特殊な材料の複雑なパターンをシリコンウエハーに堆積させることで、統合光子デバイスのチップ作成における画期的手法を開発しました。この革新は、サイズ、コスト、電力に関する重要な歴史的制約を解決し、量子コンピューティングなどの高度な技術用のコンパクトで高品質なレーザーの実現を可能にします。酸化シリコン、リチウムニオブате、タンタル五酸化物(単一のレーザー色を多様な波長に変換しつつ過度の発熱を抑える材料)を含むマルチレイヤーアプローチを用いることで、チームはビールコスター程度のサイズのパターンに、約 10,000 の光子回路を備えた指先ほどの大きさのチップを約 50 個集積することに成功しました。以前是高品質なレーザーは特定の波長(例:980 nm の赤外線)のみに存在し、量子技術の利用が専門的な研究室に限定されていましたが、この画期的進展により、携帯型光原子時計や地震予測システムといった現場での応用が可能になります。光原子時計や量子コンピュータなどの量子技術には、ルビジウム(780 nm の赤)やストロンチウム(461 nm の青)など、異なる原子に合わせた特定の色のレーザーが多く必要とされますが、この新技術はそれを効果的に解決します。この技術は、効率的な光処理を必要とする産業において量子デバイスへのアクセスを民主化するだけでなく、研究機関と Octave Photonics などのスタートアップ間の協力を促進します。*Nature* に掲載されたこれらの発見は、光子機能と電気システムを統合する道を開き、人工知能から暗物質調査に至るまでの分野を変革する可能性があります。これにより、複雑な科学ツールが従来の実験室の外でも手頃な価格で利用できるようになります。

2026/04/19 1:26

ボーイング社の B-52 ストラトフォートレス爆撃機に搭載された、スタートラッカー内の電気式傾斜計。

## Japanese 翻訳: アストロコンパスは、1960 年代の B-52 爆撃機向けに開発され、乗組員が手動で行っていた複雑な三角法の計算を自動化し、天体航法を画期的に変化させました。ジャミングに脆弱な現代デジタルシステムと異なり、この電気機械装置は、外部デジタル信号を使用せずに、シンクロン(変送機)および光増倍管を用いて恒星を追跡する信頼性の高い抗ジャミング方式を提供しました。恒星位置の物理的な追跡と天球のアナログモデルを組み合わせることで、システムはパイロットにリアルタイムでの航法更新を通じて正確な位置を決定することを可能にしました。 公式空軍年誌からのデータに基づいて動作し、この装置は時間や恒星の赤緯などの入力をノブで調整するマスターコントロールパネルを搭載していました。これらの設定は、安定したジャイロプラットフォームとガラスドーム型望遠鏡を含む組立体内の 19 のコンポーネントを駆動しました。アストロコンパスは「位置線」技術を使用し、測定された恒星の高度を計算された期待値と比較することで航空機の軌跡を特定しました。この自動化により、困難な手動手順はスムーズな電気機械プロセスへと変換され、爆撃機艦隊が重要な航法任務において、作戦能力とミッション安全性を大幅に向上させました。

2026/04/19 4:19

Claude のデザインに関する考察と感情

## 日本語翻訳: 要約:中心的な論点是、Figma の専用でロックされたファイル形式がネイティブのスキーマを持たず、深いエイリアシングおよび未文書化されたプリミティブに依存を迫られること(例:946 色の変数を含むネストされたグループのあるファイル)であり、これがコードトレーニング済みの AI モデルが設計論理を正確に解釈することを妨げる。LLM はこれらの特定の不透明な Figma 構造ではなくコードに対してトレーニングされているため、「Figma Make」のような現在のツールは、設計ファイルが依然として規範的であると示唆することでユーザーを誤導し、実質的にワークフローを新しいエージェント時代と不相容の専用エコシステムにロックしている。その対応として、Claude Design とような新たなツールは、「HTML と JS をすべて徹底する」というアプローチを取り、コードと直接統合してレポジトリから AI エージェントへ供給される統一されたフィードバックループを創出する。著者は業界における分岐の予測を行う:一方は誠実なコード連結による生産ツールへの道筋、他方は Photoshop に似た制約のない探求環境への道筋となる。クリーンなコードからのこの転換を批判しつつも、著者は Sketch などの競合業者に対してネイティブ機能に安住するのではなく、粒子エフェクトやメッシュ変換などの特定機能を備えた革新を促し、Figma の形式が AI エージェントに必要なトレーニングデータへの含まれにくさを指摘している。 ## テキストを翻訳 (必要に応じて;そうでない場合は元のものを繰り返す): ## Summary: The core argument is that Figma's proprietary, locked-down file format lacks a native schema, forcing reliance on deep aliasing and undocumented primitives (exemplified by files containing 946 color variables within nested groups) that prevent code-trained AI models from accurately interpreting design logic. Because LLMs were trained on code rather than these specific, opaque Figma structures, current tools like "Figma Make" are misleading users by suggesting the design file remains canonical, effectively locking workflows into a proprietary ecosystem incompatible with the emerging agentic era. In response, new tools like Claude Design adopt an "HTML and JS all the way down" approach, integrating directly with code to create a unified feedback loop where repositories feed AI agents. The author predicts a fork in the industry: one path toward honest, code-linked production tools, and another for unconstrained exploration environments similar to Photoshop. While criticizing this shift away from clean code, the author also urges competitors like Sketch to innovate with specific features (particle effects, mesh transforms) rather than resting on native capabilities, noting that Figma's format prevents its inclusion in necessary training data for AI agents.