Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

【実践解説】Rubyのガベージコレクションをチューニングしてパフォーマンスを改善する方法

本記事が、2017年にNate Berkopec氏によって執筆された「Understanding Ruby GC through GC.stat」という記事に基づいていることが判明しました。記事の一部に剽窃が含まれており、原著者からご指摘いただくまで私たちはその事実を認識していませんでした。公開前にはすべての記事を剽窃チェックツールにかけていますが、今回は残念ながら検出できませんでした。この不測の過誤につきまして、Nate氏ならびに読者の皆様に深くお詫び申し上げます。

Rubyアプリケーションのパフォーマンスを完全にコントロールするためには、ガベージコレクション(GC)がどのように動作するのかを理解しておくことが不可欠です。

本記事では、RubyにおけるGCの仕組みと、それをカスタマイズしてアプリの性能を引き出すための具体的な手法を詳しく解説します。

それでは始めましょう!

Rubyのガベージコレクターモジュール(GCモジュール)とは

RubyのGCモジュールは、マーク&スイープ方式のガベージコレクション機構へのインターフェースです。

通常、GCは必要に応じてバックグラウンドで自動的に実行されますが、GCモジュールを使えば任意のタイミングでGCを明示的に呼び出せるだけでなく、GCサイクルがどのように動作しているのかを細かく把握することも可能です。さらに、パフォーマンスを調整するためのパラメータもいくつか提供されています。

このモジュールで特によく使われるメソッドは以下のとおりです。

  • start / garbage_collect:GCサイクルを手動で開始します。
  • enable / disable:GCの自動実行を有効化または無効化します。処理が成功したかどうかを真偽値で返します。
  • stat:GCモジュールのパフォーマンス状況を表すキーと値の一覧を返します。詳細は次のセクションで確認します。

Ruby GCの各種パラメータを理解する

RubyのGCが内部でどう動いているのかを理解するために、まずGCモジュールが提供するメトリクスを覗いてみましょう。新しく起動したirbで次のコマンドを実行してください。

puts GC.stat

画面には大量の数値が表示され、おおよそ次のような出力になります。

{
    :count=>12,
    :heap_allocated_pages=>49,
    :heap_sorted_length=>49,
    :heap_allocatable_pages=>0,
    :heap_available_slots=>19975,
    :heap_live_slots=>19099,
    :heap_free_slots=>876,
    :heap_final_slots=>0,
    :heap_marked_slots=>16659,
    :heap_eden_pages=>49,
    :heap_tomb_pages=>0,
    :total_allocated_pages=>49,
    :total_freed_pages=>0,
    :total_allocated_objects=>66358,
    :total_freed_objects=>47259,
    :malloc_increase_bytes=>16216,
    :malloc_increase_bytes_limit=>16777216,
    :minor_gc_count=>10,
    :major_gc_count=>2,
    :remembered_wb_unprotected_objects=>191,
    :remembered_wb_unprotected_objects_limit=>312,
    :old_objects=>16024,
    :old_objects_limit=>23556,
    :oldmalloc_increase_bytes=>158824,
    :oldmalloc_increase_bytes_limit=>16777216
}

これらはランタイム内でGCがどのように行われてきたかに関する情報すべてを含んでいます。それぞれの数値を順番に詳しく見ていきましょう。

GCの実行回数を示すカウント系メトリクス

まずは次のキーから説明します。

{
    :count=>12,
    #...
    :minor_gc_count=>10,
    :major_gc_count=>2,
}

これらはGCの実行回数を表しており、意味は非常にシンプルです。minor_gc_countmajor_gc_countは、それぞれの種類のGCが実行された回数を示します。

RubyのGCには2種類あります。

Minor GCは、新しいオブジェクトだけを回収対象とするGCです。ここでいう「新しいオブジェクト」とは、GCサイクルを3回以下しか生存していないオブジェクトを指します。

一方、Major GCは、3回以上のGCサイクルを生き延びた古いオブジェクトも含めて、すべてのオブジェクトを回収対象とするGCです。countminor_gc_countmajor_gc_countの合計値です。

補足として、Ruby 2.1以降は世代別GC(RGenGC)、2.2以降はストップ・ザ・ワールド時間を短縮するインクリメンタルGCが導入されており、これらのカウントは世代別GCの動きを反映しています。

GCの実行回数を追跡すると便利な理由はいくつかあります。特定のジョブやプロセスが常にGCを発生させているのか、そして何回発生させているのかを把握できるのです。マルチスレッドアプリケーションの場合は100%正確ではないものの、メモリがどこで浪費されているのかを探る良い出発点になります。

ヒープ関連の数値:スロットとページ

次に、ヒープ数値(heap numbers)とも呼ばれる次のキー群について説明します。

{
    # ページ関連
    :heap_allocated_pages=>49,
    :heap_sorted_length=>49,
    :heap_allocatable_pages=>0,
 
    # スロット関連
    :heap_available_slots=>19975,
    :heap_live_slots=>19099,
    :heap_free_slots=>876,
    :heap_final_slots=>0,
    :heap_marked_slots=>16659,
 
    # EdenページとTombページ
    :heap_eden_pages=>49,
    :heap_tomb_pages=>0,
}

ここでいうヒープとはC言語レベルのデータ構造であり、現在生存しているすべてのRubyオブジェクトへの参照を保持しています。ヒープのページ(page)は複数のメモリスロットで構成され、各スロットには1つの生存中のRubyオブジェクトの情報だけが格納されます。

  • heap_allocated_pages:現在確保されているヒープページの数です。ページは空の状態・満杯の状態・部分的に埋まった状態のいずれにもなり得ます。
  • heap_sorted_length:ヒープが実際にメモリ上で占めているサイズです。heap_allocated_pagesとは異なり、こちらはページの個数ではなく、ページを連結した際の長さを表します。たとえば最初に10ページ確保し、その中央の1ページを解放した場合、heap_allocated_pagesは9になりますが、heap_sorted_lengthは10のままです。
  • heap_allocatable_pages:Rubyが現在所有していて、必要なときに利用できるヒープページの数です。

続いてスロット関連の項目です。

  • heap_available_slots:ヒープページ内にある利用可能なスロットの総数です。
  • heap_live_slots:メモリ上に存在する生存オブジェクトの数です。
  • heap_free_slots:確保済みヒープページの中で空になっているスロットの数です。
  • heap_final_slots:ファイナライザ(finalizer)が登録されたオブジェクトを持つスロットの数です。ファイナライザとは、オブジェクトが解放される際に実行されるProcのことで、オブジェクト指向言語におけるデストラクタに近いものです。
  • heap_marked_slots:古いオブジェクト(GCサイクルを3回超えて生存したオブジェクト)と、ライトバリアで保護されていないオブジェクトの合計数です。

さらに、tomb_pageseden_pagesという項目もあります。

tomb_pages(墓場ページ)は、生存オブジェクトを一切含まないページの数です。これらのページは最終的にRubyからOSへ返却されます。

逆にeden_pages(エデンページ)は、少なくとも1つでも生存オブジェクトを含むページの数で、OSへは返却できません。

アプリケーションでメモリ肥大(memory bloat)の問題に直面している場合は、heap_free_slotsというメトリクスの監視を検討してみてください。

フリースロットの数が25万を超えるような高い水準にある場合、多くのオブジェクトを一括で確保した後に解放するコントローラアクションが少数存在している可能性が高いです。これは稼働中のRubyプロセスのサイズを恒久的に膨らませる原因となります。

累積値(プロセス生存期間中の合計)

{
    :total_allocated_pages=>49,
    :total_freed_pages=>0,
    :total_allocated_objects=>66358,
    :total_freed_objects=>47259,
}

これらの数値は、プロセスの全寿命期間にわたって加算され続ける累積値です。GCによってリセットされることはなく、技術的には減少しません。4つとも名前の通り直感的に理解できる指標です。

GCが発動するしきい値

これらの数値を理解するには、まずGCがいつ発動するのかを知る必要があります。

{
    :malloc_increase_bytes=>16216,
    :malloc_increase_bytes_limit=>16777216,
    :remembered_wb_unprotected_objects=>191,
    :remembered_wb_unprotected_objects_limit=>312,
    :old_objects=>16024,
    :old_objects_limit=>23556,
    :oldmalloc_increase_bytes=>158824,
    :oldmalloc_increase_bytes_limit=>16777216
}

「GCは一定間隔で実行される」と思われがちですが、実際にはRubyがメモリ領域を使い果たしそうになったときにGCが発動します。free_slotsが枯渇するとMinor GCが走ります。

そして、Minor GCを実行してもfree_slotsが依然として不足している場合や、oldmalloc・malloc・旧世代オブジェクト数・shady(ライトバリア非保護)オブジェクト数のいずれかのしきい値を超えた場合には、Major GCが発動します。上記のGC.statの出力は、これらのしきい値の値を示しています。

malloc_increase_bytesは、これまで説明してきたヒープ以外の場所で確保されたメモリ量を指します。オブジェクトのサイズが標準的なスロットサイズ(たとえば40バイト)を超えると、Rubyはそのオブジェクト専用の場所をmallocで別途確保します。この追加確保分の合計がmalloc_increase_bytes_limitを超えるとMajor GCが発動します。

oldmalloc_increase_bytesは、旧世代オブジェクトに対する同様のしきい値です。old_objectsは「古い(old)」とマークされたオブジェクトスロットの数であり、この数がold_objects_limitを超えるとMajor GCが発動します。

remembered_wb_unprotected_objectsは、ライトバリアで保護されておらず、かつリメンバードセット(remembered set)に属するオブジェクトの総数です。

ライトバリアとは、Rubyランタイムとオブジェクトとの間のインターフェースであり、インタプリタがオブジェクト生成時点からその参照の出入りを追跡できるようにする仕組みです。

C拡張はライトバリアを経由せずにオブジェクトへの新しい参照を作れてしまうため、その場合対象オブジェクトはshady、すなわち「ライトバリア非保護」として扱われます。リメンバードセットとは、少なくとも1つの新しい(new)オブジェクトへの参照を持つ古い(old)オブジェクトのリストのことです。

環境変数によるGCパフォーマンスのカスタマイズ

RubyのGCがアプリケーションのメモリをどのように管理しているのかが理解できたところで、次はGCの挙動をカスタマイズするための選択肢を見ていきましょう。

Ruby GCのパフォーマンスを調整し、ひいてはアプリケーション全体の性能を向上させるために利用できる環境変数は以下のとおりです。

RUBY_GC_HEAP_INIT_SLOTS
RUBY_GC_HEAP_FREE_SLOTS
RUBY_GC_HEAP_GROWTH_FACTOR
RUBY_GC_HEAP_GROWTH_MAX_SLOTS
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
and other variables

重要なパラメータを一つずつ見ていきます。

  • RUBY_GC_HEAP_INIT_SLOTS:Rubyヒープの初期スロット数を定義します。デフォルト値は10000です。アプリ起動時に大半のオブジェクトを確保することが分かっているなら、この値を変更する価値があります。
  • RUBY_GC_HEAP_FREE_SLOTS:GCサイクル直後に確保しておくべきフリースロットの最小数を制御します。デフォルト値は4096です。この値はランタイム中、最初のヒープ拡張時に一度だけ使用されます。
  • RUBY_GC_HEAP_GROWTH_FACTOR:Rubyインタプリタに割り当てられるヒープの増加倍率です。デフォルト値は1.8です。Ruby自体がすでに積極的にヒープを拡張するため、この値を変更する意味はほぼありません。現代のインタプリタではヒープはオンデマンドで割り当てられるため、減らしても大きな効果は期待できません。
  • RUBY_GC_HEAP_GROWTH_MAX_SLOTS:Rubyが一度にヒープへ追加できるスロット数の上限です。デフォルト値は0で、無制限を意味します。アプリのライフサイクル全体で数百万個のオブジェクトを確保する必要があるなら、このパラメータに上限を設けるとよいでしょう。ただし、GC時間への影響はかなり小さくなります。
  • RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR:メモリ上の旧世代オブジェクトの総数が「この係数 × 前回GC後の旧世代オブジェクト数」を超えたときに、Major GCサイクルを強制的に実行させます。多くのオブジェクトが旧世代に入った後に不要になることが予想されるなら、この値を引き上げるとよいでしょう。ただし、実際に必要になるケースは稀です。
  • RUBY_GC_MALLOC_LIMITは新世代におけるmalloc呼び出しの最小しきい値で、デフォルトは16MBです。RUBY_GC_MALLOC_LIMIT_MAXは同じmalloc呼び出しの最大しきい値で、デフォルトは32MBです。アプリが平均より多くのメモリを使う場合、これら2つの上限を引き上げるとよいでしょう。ただし、上げすぎるとピーク時のメモリ消費が増大する恐れがあるため注意が必要です。必ず4MB〜8MB程度ずつ段階的に増やしてください。
  • RUBY_GC_MALLOC_LIMIT_GROWTH_FACTORは新世代のmallocしきい値の増加倍率で、デフォルトは1.4です。アプリがメモリを一括ではなくチャンク単位で確保する傾向があるなら、この値を引き上げることを検討しましょう。
  • 同様に、RUBY_GC_OLDMALLOC_LIMITRUBY_GC_OLDMALLOC_LIMIT_MAXは旧世代におけるmallocしきい値の最小値・最大値です。デフォルト値はそれぞれ16MBと128MBです。
  • RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTORはこのしきい値の増加倍率で、デフォルトは1.2です。新世代側のしきい値と併せて調整することで、最大限の効果が期待できます。

まとめ:RubyのGCファインチューニング

本記事では、アプリケーション全体のパフォーマンス改善に役立つ、GCモジュールの一般的かつシンプルなカスタマイズ方法を紹介しました。ただし、これらの調整がすべてのケースで機能するわけではありません。何をカスタマイズすべきか判断する前に、まず自分のアプリのメモリ使用パターンを把握することが大切です。

逆説的ですが、これらのパラメータの最適値を自動的に探索するテストを実行するのも有効な手段です。TuneMyGCのようなツールを使えば、自分の環境に最適な環境変数の組み合わせを簡単に特定できます。

アプリの挙動が奇妙だと感じたら、ぜひGCのパラメータを確認してみてください。小さな変更が、アプリのメモリ消費量の削減やメモリ肥大の防止に大きく寄与することがあります。

本記事を通じて、RubyのGCモジュールをカスタマイズする際に注目すべきポイントをつかめたのであれば幸いです。さらに基礎から学びたい方は、「ガベージコレクション入門」のPart 1およびPart 2もあわせてご覧ください。

それでは、楽しいコーディングを!

  1. Rubyで学ぶリンクリストの基礎と実装:配列との違いからコード例まで徹底解説

    本記事は「Practical Computer Science in Ruby」シリーズの第3回です。今回はリンクリスト(連結リスト)について詳しく解説します。 リンクリストとは何か? 名前の通り、リンクリストとはデータをリスト形式で格納するためのデータ構造です。 「リンク(連結)」という言葉が示すように、データはノードと呼ばれる単位に保存され、これらのノードが順番に互いに連結される仕組みになっています。 リンクリストと配列の違い リンクリストは配列とは異なるパフォーマンス特性を持っています。それが、用途に応じてどちらかを選ぶ理由の一つです。つまり、特定のタスクにおいては、リンクリストの方が

  2. Rubyで学ぶ実践グラフ理論:基礎からアルゴリズム活用まで

    本記事は「実践コンピュータサイエンス」シリーズの続編です。古典的なコンピュータサイエンスの概念を、Rubyを使って実際の問題解決に応用する方法を学んでいきます。 今回のテーマはグラフ理論です。 二分木(バイナリツリー)という言葉を耳にしたことがある方も多いでしょう。二分木は次のような構造をしています。 実は、二分木とはグラフの特殊な一種にすぎません。このことからも、グラフがいかに広く普及したデータ構造であるかがわかります。 まずはグラフ理論の基礎を概観し、その後、実用的な活用例とRubyでの実装方法を見ていきましょう。 グラフの基本 グラフは次の2つの要素で構成されます。 ノード(頂点とも