🤖

YJITはじめました

に公開

はじめに

社会人二年目になりましたEXcEption2zeroです。
本記事では発達が気になる子どもの保護者や支援者向けポータルサイトの発達ナビにおいてYJITを有効化したので、その効果と知見についてまとめました。

この記事はLITALICO Advent Calendar 2025その3、14日目の記事です。

YJITの効果

まず気になるであろうYJITの効果から見ていきます。
動作環境はRuby3.3, Rails7.2となっております。

アプリケーションが頻繁に更新される都合上、比較的更新が少なく比較しやすいトップページのp50 Latencyについて比較します。左がYJIT有効(改善後)、右が無効(改善前)です。
トップページのLatency比較
平均が104ms -> 86msとなっており、20%ほど短縮されています。

また、ログを収集するエンドポイントがあり、そこは別のコンテナで実行しているのでそちらも同様に比較します。こちらも左がYJIT有効(改善後)、右が無効(改善前)です。
ログエンドポイントのLatency比較
受け取ったログをfluentdに送る単純でYJITが効きやすいケースというのもあり、平均で8.2ms -> 5.5msと33%ほど短縮されています。

YJITとは

既にYJITが出てそれなりに経っており、多数有用な記事も出ているため概要のみ書きます。

まず、JITは実行時にコンパイルする方式のことで、インタープリターのみで実行する場合に比べ最初のコンパイルのオーバーヘッドはあるものの高速な動作をすることができます。
YJITは全てのコードをコンパイルするのではなく、頻繁に呼び出されるもののみを選択的にコンパイルすることで効果的な部分のみコンパイルします。
そのため、YJITは初期のコンパイルのオーバーヘッドを減らしつつ高速化できるものとなっています。

JITの一般的な特性としてコンパイルしたものを保持する必要があるためメモリ使用量が増加するデメリットはありますが、YJITを有効にすることで手軽にパフォーマンスを向上させることができます。

YJITのチューニング

YJITを有効化してパフォーマンス改善!となれば良かったのですが、実際には課題が発生したためチューニングを行いました。
次のグラフはサイト全体のp90のLatencyとデプロイタイミングで、紫線が出ているところがデプロイしたタイミングです。
デフォルトのスパイク
デプロイのタイミングでLatencyがスパイクしていることがわかります。これはYJITを有効化する前は見られない現象でした。
初期のコンパイルのオーバーヘッドを減らしたYJITでも、状況によっては無視できないオーバーヘッドが発生します。

YJITの設定

YJITのチューニングに使える設定項目を抜粋したものを以下に記します。

  • --yjit-exec-mem-size
    • YJITがコンパイルした機械語を保存するメモリ制限。制限に達するまでコンパイル可能。
    • ただし、実際にYJITが消費するメモリはメタデータ分が追加される。
      • そのためこの設定の最大4〜5倍ほどメモリを使用する。
    • また、この設定は1プロセスあたりの設定。
  • --yjit-call-threshold
    • 命令をコンパイルするか判断する呼び出し回数。(詳細は後述)
  • --yjit-cold-threshold
    • 命令がよく呼ばれているか判断する回数。(詳細は後述)

なお、Ruby3.4からメモリの設定が変わり、YJITが機械語を保存するメモリ量の制限からメタデータを保存するメモリ量も合わせた制限ができるようになりました。

参考
YJITのドキュメント for Ruby3.3

YJITのメトリクス

実際に測定を行わないと、設定が適切か判断できないためどのようなものが測定できるかも記します。
YJITはRubyVM::YJIT.runtime_statsで統計を提供しており、チューニングの参考になる値を抜粋し以下に記します。

  • yjit_alloc_size
    • YJITがメタデータを保存するのに使用しているメモリ量
  • code_region_size
    • YJITが機械語を保存するのに使用しているメモリ量
  • compiled_iseq_entry
    • コンパイルされた命令の数
  • cold_iseq_entry
    • コンパイルしないと判断された命令の数
  • vm_insns_count
    • Rubyを起動してから実行された命令の数
  • compile_time_ns
    • コンパイルにかかった時間

また、オーバーヘッドはありますがより詳細な統計を提供するようになる--yjit-statsオプションが存在し、以下の値が有用です。

  • ratio_in_yjit
    • 実行されたコードの内YJITが使われている割合

これらのメトリクスですが1つ注意があり、プロセス毎に値が異なります。なので、UnicornやPumaのクラスターモードを使用している場合はどのプロセスで取得したものかわかるようにしないと混乱する場合があります。
(DatadogはYJITのメトリクスを良い感じに取得してくれる設定があるがプロセス毎にうまく取れていない模様)

参考
利用できる統計はこのファイルのrb_yjit_gen_stats_dictに定義されている。

YJITがコンパイルするまでの流れ

YJITが命令をコンパイルするかどうか判断するまでの流れを簡単に説明します。

  1. Rubyがプログラムを実行する際、インタープリターがISEQというデータ構造にコンパイルする
  2. YJITはそのISEQが何回呼ばれたか記録する
    • 同様に全体のISEQが何回呼ばれたかも記録している
  3. (--yjit-call-threshold - 20)回呼び出された時に全体のISEQが呼ばれた回数を記録する
    • この「20」というタイミングはRubyのバージョンによって変わる可能性あり
  4. --yjit-call-threshold回呼び出された時に全体のISEQが呼ばれた回数を確認し、差分が--yjit-cold-thresholdより小さければコンパイルし、大きければコンパイルしない
    • よく呼ばれているもののみコンパイルする

そのため、--yjit-call-thresholdはコンパイルするタイミングを制御し、--yjit-cold-thresholdはコンパイルする割合を制御します。

参考
このロジックはここにある。

チューニング結果

以上のことから、--yjit-call-thresholdの値を大きくすることにしました。
一般的にそれぞれのISEQの実行回数は時間に比例するものと考えられるため、値を大きくすることでコンパイルするタイミングを遅らせ、分散させることができると考えたためです。これによりYJITが効果を発揮するのに時間がかかるようになりますが、スパイクが小さくなることが期待できます。
具体的な数値としては350としました。算出の根拠はcompile_time_nsの傾き(=単位時間あたりのコンパイル時間)で、立ち上がり時とスパイク収束後を比較したところ収束後の傾きは約1/3でした。そのため、立ち上がり時のコンパイル負荷を収束後のレベルまで抑えるには、閾値をデフォルトの約3倍にする必要があると判断しました。なお、vm_insns_countが起動時の段階で40000を超えており、この状態での--yjit-call-thresholdは120となります。その3倍に近い、キリの良い数値として350を採用しました。

また、歴史的経緯によりPumaを1スレッドで多数ワーカーを起動するようになっていましたが、YJITはプロセス毎に独立して動作するため、別プロセスで同じコンパイルが行われ無駄が発生しており、オーバーヘッドが大きくなっていると考えたため、Railsデフォルトの3スレッドにしてワーカー数を減らしました。

結果として次のグラフとなり、スパイクを一定抑えることができました。
チューニング後のスパイク

また、パフォーマンスとしても(アクセス数が少ないため変動が大きいですが)ほぼ変わりませんでした。左がチューニング後、右がデフォルトです。

チューニング前後のパフォーマンス変化

おわりに

YJITは手軽にパフォーマンス向上できる選択肢です。しかし、場合によっては好ましくない事象が発生することがあります。その際にこの記事が助けになれば幸いです。
一方、Ruby4.0では新しいZJITが登場するようです。気になりますね。

LITALICO Advent Calendar 2025はまだまだ続きます。興味のある方はぜひご覧ください。

LITALICOエンジニア

Discussion