RubyKaigi 2025 キーノートレポート

Ivo Anjoさん「Performance Bugs and Low-level Ruby Observability APIs」 RubyKaigi 2025 2日目キーノート

RubyKaigi 2025の2日目のキーノートとして、Ivo Anjoさんが「Performance Bugs and Low-level Ruby Observability APIs」というタイトルで発表を行いました。

Ivo AnjoさんはDatadogでシニアソフトウェアエンジニアとして働き、DatadogのRubyプロファイラを開発しています。今回のキーノートでは、CRubyの動作の詳細を追うためにどういったメトリクスを取得するとよいのか、そうした低レイヤに触れるにはどんなAPIが活用できるのかなどについて、詳しく解説しました。

キーノートセッションの様子

はじめに

Ivo AnjoさんはDatadogのRubyプロファイラを開発することになった理由について、⁠アプリケーションを様々な方法で観察するツールを作り、探求することが本当に楽しいから」と語っています。

Ivoさんの作るツールは、RubyアプリケーションのさまざまなメトリクスをDatadogで詳細に観察することに活用されています。この発表では、そうしたメトリクスの取得に使われているRubyの低レベルオブザーバビリティAPIについて詳しく解説しました。

Datadogプロファイラ画面

低レベルオブザーバビリティとは

この発表で言及する低レベルオブザーバビリティAPIとは、CRuby内部のVM実装に近い、通常は知る必要がなく意識することのないAPIです。

低レベルオブザーバビリティAPIを使うことで、アプリケーション内部で何が起きているのか、なぜそれが起きているのかを詳細に理解できます。通常のRubyアプリケーション開発では見ることのできない、VMレベルでの動作や状態を観察することが可能になります。

しかしなぜ低レベルオブザーバビリティAPIが必要なのでしょうか?

テストはパスし、アプリケーションが正常に動作しているにもかかわらず処理が遅い場合、その原因は様々考えられます。

  • N+1クエリの問題が潜んでいる場合
  • 想定以上にデータベースにアクセスしている場合
  • 接続先外部サービスが遅延している場合
  • バックグラウンドスレッドがCPUを大量に消費している場合
  • GCが大量のオブジェクトを収集している場合

これらの問題を特定し解決するために、低レベルオブザーバビリティAPIは重要な役割を果たします。そして、これらのAPIを使うことでそういった問題を特定するためのツールを作ることができます。

“Rubyは遅い”問題?

しばしば語られる⁠Rubyは遅い⁠問題[1]についても、アプリケーションを正しく観測する必要があります。作成したアプリケーションが遅かったとき、データベースへのアクセスパターンは適切だったか、コネクションプールが小さすぎなかったか、スレッドを作りすぎていなかったかなど、正しく問題を特定することが重要です。

それらの問題を特定し適切に対処することで、Rubyアプリケーションは十分な性能を発揮します。

プロファイラの開発というパフォーマンスの専門家であるIvoさんが「Ruby is really fast」と語ったのは、とても印象的でした。

既存のツール

プロファイラは既に数多く存在しています。Datadog Continuous Profiler、StackProf、Vernier、GVLTools、Byebug、他にも様々あります。こうしたツールはすべて、これらの低レベルオブザーバビリティAPIを基盤として構築されています。

低レベルオブザーバビリティAPIはこういった様々なデバッガやプロファイラといったツールを作るための基盤となっています。それぞれのツールは同様の基盤を使っていますが、様々な異なる価値を提供するツールとなっています。

低レベルオブザーバビリティAPIを知ることで、そしてこれまでにない組み合わせを考えることで、既存のツールにない新たな価値を提供するツールが作れるかもしれません。

Ivoさんはそうした新しいツールを見たいし、これらの低レベルオブザーバビリティAPIを改善するためにも是非会話していきたいと語っています。

低レベルオブザーバビリティAPIの全体像

Rubyが低レベルオブザーバビリティAPIとしてどんな機能を提供しているのか、どんな問題を調査できるのか見ていきましょう。

これらのAPIは主に2つのソースファイルに集中しています。

  1. include/ruby/debug.h
    • TracePoint API
    • Postponed Job API
    • Frame-profiling API
    • Debug inspector API
  2. include/ruby/thread.h
    • GVL instrumentation API
    • Thread specific storage API

この発表では、これらのAPIを簡単に使うために作成したlowlevel-toolkitというgemを利用して説明が行われました。lowlevel-toolkitはRubyの内部をより細かく見るためのライブラリとして使うこともできますし、低レベルオブザーバビリティAPIの使い方を示す生きたサンプルコードとしても使えます。

TracePoint API

TracePointはRubyの標準APIとしてよく知られていますが、CRubyには内部的なTracePointイベントが存在します。これらのイベントはCコードからのみアクセス可能で、通常のRubyコードからは利用できません。

  • RUBY_INTERNAL_EVENT_SWITCH
  • RUBY_INTERNAL_EVENT_NEWOBJ
  • RUBY_INTERNAL_EVENT_FREEOBJ
  • RUBY_INTERNAL_EVENT_GC_START
  • RUBY_INTERNAL_EVENT_GC_END_MARK
  • RUBY_INTERNAL_EVENT_GC_END_SWEEP
  • RUBY_INTERNAL_EVENT_GC_ENTER
  • RUBY_INTERNAL_EVENT_GC_EXIT

例えば、NEWOBJイベントを使うとオブジェクト生成を追跡でき、アプリケーション内でGCによるWebリクエスト遅延が起きていないか、無駄なオブジェクト生成オーバーヘッドが発生していないかといった問題を調査できます。

ここでは、lowlevel-toolkitではNEWOBJイベントを用いてどのようにこのイベントを取得し、オブジェクト生成を追跡できるようにしているのかを説明しました。

lowlevel-toolkitでは以下のようなRubyコードからオブジェクト生成を追跡できます。

pp(LowlevelToolkit.track_objects_created do
  Object.new
  Time.utc(2025, 4, 17, 1, 0, 0)
  "Hello, world!"
end)

GC_ENTER, GC_EXITイベントを使ってGC時間を計測するprint_gc_timingというメソッドも提供しています。

LowlevelToolkit.print_gc_timing do
  puts "Minor GC:"
  GC.start(full_mark: false)
  puts "Major GC:"
  GC.start(full_mark: true)
end

ただし、これらの内部イベントは本来Rubyユーザには隠されたものであり、ほとんどのRuby APIが使えない状態です。よって注意深く扱わなければ、非常に紛らわしいクラッシュなどを招くことがあります。

またTracePointを用いてパフォーマンス調査をする場合、include/ruby/debug.hの最後に記述されている、ドキュメントされていないがオーバーヘッドが少し少ないrb_add_event_hook2, rb_thread_add_event_hook2というAPIを使うべきであることを紹介しました。

PostponedJob API

PostponedJob APIは、Rubyの"Safepoint"でコードを実行するためのAPIです。Safepointという言葉はVMの状態が安定してRubyコードを実行可能な時点を指します。

このAPIは以下のような場面で特に有用です。

  • 低レベルAPIからRubyコードを安全に呼び出したい場合
  • GCやVM操作中に特定の処理を遅延実行したい場合
  • スレッドセーフな処理を実装したい場合

プロファイラのような低レイヤに触れるツールを作る場合はこのような制限から逃れる方法としてのPostponedJob APIはとても重要です。

例えばlowlevel-toolkitでは以下のようにGC_EXITイベントとPostponedJob APIを組み合わせて、GC終了時に呼び出すメソッドを登録するAPIを提供しています。

static rb_postponed_job_handle_t postponed_id = rb_postponed_job_preregister(0, postponed, NULL);

VALUE on_gc_finish(VALUE _, VALUE user_callback) {
  callback = user_callback;
  VALUE tp = rb_tracepoint_new(0, RUBY_INTERNAL_EVENT_GC_EXIT, on_gc_finish_event, NULL);
  ...
}

void on_gc_finish_event(VALUE _, void *__) {
  rb_postponed_job_trigger(postponed_id);
}

void postponed(void *_) { rb_funcall(callback, rb_intern("call"), 0); }

on_gc_finishで渡されたコールバックメソッドはGC_EXITイベント発生時にPostponedJob APIで登録されたメソッドの中から呼ばれます。このメソッドが実行されるのはSafepointであるため、以下のようにRubyコードを安全に呼び出すことができます。

at_finish = -> do
  kind = GC.latest_gc_info[:major_by]
  puts "GC finished (#{kind ? "major (#{kind})" : "minor"})"
end

LowlevelToolkit.on_gc_finish(at_finish) do
  GC.start(full_mark: false)
  GC.start(full_mark: true)
end

# => GC finished (minor)
# => GC finished (major (force))

PostponedJob APIを使う際には以下のような注意点があることも取り上げました。

  • Ruby 3.3で大きく設計が変更されたこと
  • トリガーフラグはスレッドローカルであること
  • 登録可能ジョブ数は31個までであること
  • 呼び出されるタイミングは別メソッドの実行中かもしれないこと

Frame-profiling API

Frame-profiling APIはバックトレース取得のための低レベルAPIです。Datadog、Stackprof、Vernier、Pf2など、多くのRubyプロファイラの基盤となっているAPIです。

このAPIはオブジェクト割り当てが不要で低オーバーヘッドであり、シグナルハンドラ内でも使用可能という特徴があります。そのため、プロファイラ実装においては非常に重要なAPIです。

このAPIはThread#backtrace_locationsと非常によく似た低レベルAPIです。

lowlevel-toolkitではFrame-profiling APIを組み合わせて最後のオブジェクト生成時のスタックトレースを取得するAPIを提供しています。

def hello
  Object.new
  nil
end

LowlevelToolkit.track_last_allocation_at do
  hello
  pp LowlevelToolkit.last_allocation_at
end

# => [["examples/newobj_backtrace.rb", "hello"], ["examples/newobj_backtrace.rb", "block in <main>"], ["examples/newobj_backtrace.rb", "<main>"]]

Frame-profiling APIを使う際にはオブジェクトを生成し過ぎず使いまわすこと、GC管理外のオブジェクトを作成してしまわないことなど、lowlevel-toolkitのコードを例に説明しました。

Debug Inspector API

Debug Inspector APIは、現在のアクティブなバックトレースとメソッドを調べるための強力なAPIです。Frame-profiling APIと比べてコストは高いものの、より詳細な情報にアクセスできます。

またFrame-profiling APIとは異なり、オブジェクト割当もおこないますし、処理速度も遅いです。そのためDebug Inspector APIはプロファイラではあまり使われず、主にデバッガーの実装に使用されています。

このAPIでは以下のようなことができます。

  • メソッドが呼び出されているオブジェクトへのアクセス
  • bindingオブジェクトへのアクセス
  • 命令シーケンスへのアクセス

発表内ではこのAPIを用いて呼び出し元や呼び出し元のbindingを取得する方法を解説し、そうした機能を提供するdebug_inspector/binding_of_callerというgemが既に存在していることも紹介しました。

GVL Instrumentation API

RubyにはGlobal VM Lock (GVL)の動作を確認するGVL Instrumentation APIが存在し、以下のようなイベントを監視できます。

  • RUBY_INTERNAL_THREAD_EVENT_STARTED
  • RUBY_INTERNAL_THREAD_EVENT_READY
  • RUBY_INTERNAL_THREAD_EVENT_RESUMED
  • RUBY_INTERNAL_THREAD_EVENT_SUSPENDED
  • RUBY_INTERNAL_THREAD_EVENT_EXITED

このAPIを使ってGVLの動作を確認することでマルチスレッドアプリケーションのスレッド動作状況を正確に把握できます。

Ivoさんの作成したgvl-tracing gemを利用するとGVLの動作をビジュアライズし理解できます。

gvl-tracing gemによるビジュアライズ

またlowlevel-toolkitに実装済みの LowlevelToolkit.track_wants_gvlというGVL待ちしていた時間を計測するメソッドを例に、これらのAPIを用いてどのようにGVLの動作を追えるのかを解説しました。

また、以下のような点についても言及がありました。

  • RESUMEDイベントはGVLを獲得したスレッドのみ発生すること
  • RESUMEDイベント以外のイベント発生時はGVLが取得できていないため、ほとんどのRuby APIは呼べないこと
  • rb_internal_thread_specificはGVLなしで情報を記録する安全な手段であること
  • gvltoolというGVLに関する情報を提供する便利なgemが既に存在すること

新しいプロファイラを作ってみよう

Ivo Anjoさんは、これまで説明してきた低レベルAPIを組み合わせて、新しいタイプのプロファイラ「Release GVL profiler」を作成する方法を紹介しました。

Release GVL profilerは、アプリケーション内でGVLが明示的にリリースされるタイミングを追跡するプロファイラです。

Release GVL profilerではこれまで解説してきたTracePoint API、GVL Instrumentation APIを利用しています。このプロファイラはlowlevel-toolkitにrelease_gvl_profilerという名前で実装済みで、107行のコードでGVLの詳細を追うプロファイラが実現できています。

ここではこれまでに解説してきたAPIを組み合わせて、speedscopeのような便利なビジュアライザが対応する形式で出力を生成するだけで、簡単に独自のプロファイラを作れることを強調していました。

“Performance bugs”

ここまで低レベルオブザーバビリティAPIの具体的な使い方を解説してきたIvoさんですが、最後に⁠Performance bugs⁠についての考え方を話しました。

「パフォーマンスの「バグ」は、必ずしも機能的なバグとは異なり、影響度と優先度を考慮することが重要です。

本番環境を壊してしまっているなら、ユーザ体験を著しく損なっているなら、アクセス集中でサービスがダウンしているなら、コストが耐えられないぐらい上がっているなら、対応する必要があります。それらを改善するための観測するツールが存在しています。

私たちはいつでも最適化したいという衝動にかられますが、時にはそれは最適化すべきでないこともあります。よく考えて判断する必要があります」

Ivoさんは発表の最後に、この発表を聞いた人がこれらのAPIを用いてどのようなツールを作るのか、どんな「すべきでない」ことをするのか、とても楽しみにしていると語っていました。

まとめ

この発表の中で紹介された低レベルオブザーバビリティAPIはRubyアプリケーションの動作を深く理解し、パフォーマンス問題を特定するための強力な道具となっています。

Rubyアプリケーションの内部を深く知りたい人、プロファイラやデバッガを作りたい人にとって非常に参考になるRubyKaigiらしいキーノートでした。もしそういったことをしてみたくなった人は、ぜひIvoさんの発表内容やlowlevel-toolkitのコードを参考にチャレンジしてみてはいかがでしょうか。

おすすめ記事

記事・ニュース一覧