Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Puma 5がリリース!スリープソートによる高速化など(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

puma/puma - GitHub

Puma 5がリリース!スリープソートによる高速化など(翻訳)

概要: Puma 5は当プロジェクトの大きなメジャーリリースであり、実験的な新パフォーマンス機能がいくつも導入されたほか、多数のバグ修正や機能追加も行われました。その中でも最も重要な目玉機能についていくつかお話しいたします(1839 word/7分)。

Puma 5(コードネーム Spoony Bard1)が本日リリースされました(私の誕生日です!)。このリリースにはさまざまなものが盛り込まれていますので、Pumeユーザーの皆さまが自信を持ってアップグレードできるよう、Pumaのさまざまな機能や変更点についてお話しいたします。

MRI + クラスタモードでの実験的パフォーマンス機能

今回のリリースの見出しを飾るのはたぶんこれでしょう。メモリ使用量を削減する機能が2つと、レイテンシを削減する機能が1つ加えられました。

Puma 5には以下の3つの実験的パフォーマンス向上機能が含まれています。

wait_for_less_busy_worker設定
ワーカーがビジー状態の場合、ソケットの再リッスン前にわずかな遅延(スリープソート!)を挿入することでMRIでのレイテンシを削減できる可能性があります。意図する結果:「この機能を有効にすると、Pumaクラスタの負荷が高い(利用率50%越え)場合のレイテンシを削減するはず」。
fork_workerオプションとreforkコマンド
fork元をマスタープロセスではなくワーカープロセスのひとつとすることでメモリ使用量を削減します。意図する結果:「この機能を有効にするとメモリ使用量が削減されるはず」。
nakayoshi_fork設定オプションの追加
(可能な場所で)forkとコンパクションの前にGCを行うことで、プリロード済みのクラスタモードアプリのメモリ使用量を削減します。意図する結果:「この機能を有効にするとメモリ使用量が削減されるはず」。

3つの実験的機能は、いずれもMRI上で動作するクラスタモードのPuma設定でのみ利用可能です。

これらの機能を「実験的」と呼ぶ理由は、これらの機能で実際にメリットを得られるかどうかについて確信がないためです。これらの機能が安定していることと何かを壊したりしないことについてはかなり確信していますが、現実世界で実際に大きなメリットを得られるかどうかについてはまだわかりません。一般的な負荷というものは私たちの予想どおりにならないことが多く、ベンチマークをあれこれツギハギしたところで、ある変更点が有用かどうかを見極める手がかりとしては役に立たないのが普通です。

私たちは、これらの新機能がアプリケーションに悪影響を及ぼしたり安定性を損なったりすることはないと信じています。これらの実験的機能は単に「効果あり」か「効果なし」のどちらかになります。

これらの機能のいずれかが特に有用であることが判明すれば、Pumaの今後のバージョンでデフォルトにする可能性もあります。

Pumaをアップグレードして3つの実験的機能のどれかを試してくださる方がいらっしゃいましたら、ぜひアップグレード前とアップグレード後のスクショをGitHub issue #2258に貼ってお知らせください。「何も変わらなかった」という情報も有用なレポートです。「アップグレード前の24時間」と「アップグレード後の24時間」のデータを投稿いただけると一番助かります。

wait_for_less_busy_worker: スリープソートによるアプリ高速化とは?

この機能はGitLabがPumaに貢献してくださったものです。Puma設定ファイルにwait_for_less_busy_workerを追加することで機能をオンにできます。

Pumaクラスタから何かひとつリクエストが到着すると、OSはリクエストを拾うために「フリー(free)」かつ「リッスン中(listening)」のPumaワーカープロセスをランダムにひとつ選択します。ここで重要なのは「フリー」と「リッスン中」という用語で、Pumaプロセスは「何もすることがない」場合にのみソケットをリッスンします。しかしPumaをマルチスレッドで実行する場合、Pumaは「ビジースレッドがすべてI/O待ち状態」の場合や「GVL(Global VM Lock)が解放される」場合にもソケットをリッスンします。

GitLabはUnicornからPumaへの乗り換えを調査していたときに、この振る舞いを伴う問題に遭遇しました。ほどほどのスレッド設定(ここでは最大プールサイズが5)で高負荷をかけるとリクエストの平均レイテンシが増加したのです。なぜでしょう?

先ほど私が、OSはリクエストを「リッスン中の」ワーカープロセスにランダムに割り当てると申し上げたことを思い出してください。すなわち、何かを処理中のビジーなワーカープロセスには決してリクエストが送信されないことになりますが、では「ひとつのワーカープロセスで4つのスレッドが他のリクエストを処理している」「しかし4つのスレッドがすべてI/O待ち状態」の場合はどうなるでしょうか?

1つのPumaクラスタ上に以下のような3つのワーカーがあるとします。

  • ワーカー1(ビジースレッド数: 0/5)
  • ワーカー2(ビジースレッド数: 1/5)
  • ワーカー3(ビジースレッド数: 4/5)

ここで、ワーカー3にある4つのアクティブなスレッドがたまたま一斉にGVLを解放し、ワーカーがソケットをリッスン可能になり、それから新しいリクエストが1つやってきたとすると、このリクエストはどのワーカーに振り分けるのが理想的でしょうか?ワーカー1が正解だと思いますよね?残念ながら、ほとんどのOSは33%の場合ワーカー3にリクエストを割り振ります。

ではどう対処すればよいでしょうか?ここでは、負荷の小さいワーカーをOSに選んでもらいたいわけです。もしここでソケットをリッスン中のワーカーリストをソートできれば、そしてOSがそれを元にして負荷が最も小さいワーカーにリクエストを割り当てられればクールだと思いませんか?これをそのまま実現するのは簡単ではありませんが、実はもうひとつ手があるのです。

wait_for_less_busy_workerは、ワーカーのスレッドプールが完全に空でなければ、ワーカーによるソケットの再リッスンに「ウエイト」をかけます。これによって、高負荷時のシナリオでOSが負荷の小さいワーカーにリクエストが割り振られるようになります。

これがワーカーの「スリープソート」の基本的な考え方です

[].tap { |a| workers.map { |e| Thread.new{ sleep worker_busyness.to_f/1000; a << e} }.each{|t| t.join} }

つまり上のような感じにすることで、最初に負荷の小さいワーカーにリッスンさせて「負荷の大きい」ワーカーをOSからうまいこと隠蔽するというわけです。

訳注: 画期的(?)なソートアルゴリズム「Sleep Sort」:濃縮還元オレンジニュース|gihyo.jp … 技術評論社

元々この手法は、もっと込み入ったソートの代替手段として提案されました(ビジーなスレッドが増えたらプロセスをより長くスリープさせる)が、単にスリープをオンオフするだけで十分効くことが判明したので、その時点でこの提案は削除されました。

この手法の真の効用は、高負荷シナリオにおいてリクエストのレイテンシが削減されることです。その理由は「ビジーなスレッドが多いワーカーは、ビジーなスレッドを持たないワーカーより遅い」という単純なものです。より高速なワーカーにリクエストが割り当てられるようにしました。このパッチを当てる前は、PumaのレイテンシがUnicornより増加することがGitLabによって観察されましたが、パッチ適用後の両者のレイテンシは同じになりました(Pumaのメモリ節約型マルチスレッド設計のおかげで、これらのフリートサイズも30%削減できました)。

今後、この振る舞いをさらに効果的に実装する方法が見つかる可能性もあります。libevを用いるマジックがいくつかあるのも確かですし、さもなければ他のスリープ/ウエイト戦略を実装すれば済むことです。

追記(2022/10/24)

後のPuma 6ではwait_for_less_busy_workerがデフォルトで有効になりました。

参考: puma/6.0-Upgrade.md at master · puma/puma

fork_worker

puma.rb設定ファイルにfork_workerを追加するか、CLIで--fork-workerオプションを指定することでこの機能をオンにできます。このモードでは、Pumaは追加のワーカーを(マスタープロセスから直接forkするのではなく)「ワーカー0」からforkするようになります。

10000   \_ puma 5.0.0 (tcp://0.0.0.0:9292) [puma]
10001       \_ puma: cluster worker 0: 10000 [puma]
10002           \_ puma: cluster worker 1: 10000 [puma]
10003           \_ puma: cluster worker 2: 10000 [puma]
10004           \_ puma: cluster worker 3: 10000 [puma]

preload_app!オプションと同様、fork_workerはコピーオンライトによるメモリー削減
のために一度だけ初期化されます。これには以下の2つのメリットがあります。

  • 1. Pumaのphased restart(段階的再起動)と互換性がある: マスタープロセス自身はアプリケーションをプリロードしないので、preload_app!と異なりこのモードはphased restart(SIGUSR1またはpumactl phased-restart)で効きます。phased restartの一環としてワーカー0が再読み込みされると、Pumaはアプリケーションの新しいコピーを初期化してから他のワーカーを再読み込みします。この再読み込みは、新しいプリロード済みアプリケーションを既に含む新しいワーカーからforkすることで行われます。

これによって、phased restartはホットリスタート(SIGUSR2またはpumactl restart)と同じぐらい短時間で完了しつつ、再起動をクラスタ上のワーカーに分散することでダウンタイムを最小化できるようになります。

  • 2. アプリケーション実行時の追加コピーオンライトを改善するreforkコマンド:「fork-worker」モードで新しく導入されたreforkコマンドは、ワーカー0以外のすべてのワーカーをワーカー0からforkすることで再読み込みします。

このコマンドは、事前初期化が起動時だけでは完了しないような大規模または複雑なアプリにおけるメモリ使用量を潜在的に改善します。その理由は、再度forkされたワーカーがコピーオンライトのメモリーを、既に動作中でリクエストを処理しているワーカーと共有できるためです。

SIGURGシグナルをクラスタに送信するかpumactl reforkコマンドを実行することでいつでもreforkをトリガーできます。reforkは、ワーカー0で一定数のリクエスト(デフォルトでは1000)を処理したときにも自動的に1回トリガーされます。自動reforkされる前のリクエスト数を設定するには、fork_workerに正の整数をひとつ渡す(例: fork_worker 1000)か、0を渡して無効にします。

nakayoshi_fork

puma.rb設定にnakayoshi_forkを追加することでこのオプションをお試しできます。

nakayoshiは日本語の「仲良し」すなわちフレンドリーという意味です。元々nakayoshiという概念は、とあるgemでMRIスーパーコントリビューターKoichi Sasadaによって実装されましたが、これをもっとシンプルにしたものをPumaに導入したらどうなるかやってみたかったのです。

基本的には、あるワーカーをforkする前に以下を行うだけです。

4.times { GC.start }
GC.compact # 可能な場合

ここでのコンセプトは、コピーオンライトのメリットを最大化する目的で、fork前にできる限りRubyのヒープをクリーンにしておこうというものです。これによってメモリ使用量が削減されるはずです。

その他の新機能

福袋に入っているその他の機能も軽くご紹介しましょう。

  • OpenSSLがインストールされていないコンピュータでもPumaをコンパイルできるようになりました。
  • アクティブなスレッドバックトレースをすべて出力するthread-backtracesコマンドがpumactlに追加されました。これはDarwin環境では既にSIGINFO経由で利用可能でしたが、この新しいコマンドによってLinuxでも使えるようになりました。
  • Puma.statsrequests_countカウンタが追加されました。
  • lowlevel_error_handlerが若干拡張され、ステータスコードも渡せるようになりました。
  • phased restartやワーカーのタイムアウトが高速になったはずです。
  • Puma.stats_hashでPumaの統計情報を(JSONではなく)ハッシュでも取れるようになりました。

多くのバグ修正

今回のリリースでは相当多くのバグが修正されました。中でも重要な修正を以下にリストアップします。

  • シャットダウンの信頼性が向上したはずです。
  • シャットダウン時のソケットのクローズに関連する問題が修正されました。
  • Reactor内でのコンカレンシーバグがいくつか修正されました。
  • out_of_bandの信頼性が向上したはずです。
  • Action Cableユーザーから報告されていた、サーバーを起動できなくなる問題が修正されました。
  • prune_bundlerのさまざまな安定性が向上しました。

内部やテストの改善

今回のリリースではテストのカバレッジで多大な改善が施されました。Puma 4.0時代と比べてテストの量が倍近く増え、安定性や再現性も向上しました。

今回のメジャーリリースでは多くの破壊的変更も発生しています。完全なリストについてはHistory.mdファイルをどうぞ。

コントリビューターに感謝です!

今回は私たちのチームに新しいメンテナーであるMSP-Gregを迎えて以来初のメジャーおよびマイナーリリースとなりました。Gregはテストスイートの信頼性向上のためにおびただしい成果を達成し、SSL機能を最新化して拡張性を高める作業でも多くの成果を残しました。Gregは私たちのチームのWindowsエキスパートでもあります。

以下は、今回のリリースで10コミット以上のコントリビューションをいただいたメンバーのリストです。

Pumaに貢献してみたい方は、こちらのコントリビューションファイルをお読みください。私たちは常に皆さんのお力添えを求めており、そのためにも皆さんができるだけ気楽に貢献できるよう取り計らっています。

Puma 5をエンジョイしましょう!

おたより発掘

関連記事

Rubyのスケール時にGVLの特性を効果的に活用する(翻訳)


  1. Pumaでは、プロジェクトで重要な貢献を多数行ってくれた「スーパーコントリビューター」が新たに加わるたびに、次回リリースの命名権を彼らに委ねています。今回のリリースではWill Jordanのコードが多数フィーチャーされており、Spoony Bardは彼の命名です。Will曰く「Final Fantasy IVはぼくにとってとりわけ懐かしいゲームで、自分が初めて関わった大規模オープンソースプロジェクトは、90年代後半にこのゲームの再翻訳に趣味で携わったときでした」(訳注: モンスター/【ぎんゆうしじん】 - ファイナルファンタジー用語辞典 Wiki*) 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。