Edited at

Ruby 2.1.1 GC Tuning

More than 5 years have passed since last update.

Ruby2.1では、RGenGCによりかなりパフォーマンスが改善されている。

また、チューニングパラメータが増えているが、まとまった日本語の解説が無かったので書いてみた。

間違いがある可能性があるので、指摘は歓迎です。


RGenGCとは

RGenGC(Restricted Generational Garbage Collection)については、まずはこれを読むべし

上記の資料でほぼ概要はつかめるはず。


Heapレイアウト


  • HeapはSlot(とPage)からなる

  • Rubyオブジェクト(RVALUE)が生成されるとSlotに配置される

  • 各Slotのサイズは40Byte

  • HeapはPageに分割されて、各Page毎にSlotがある

  • Pageは"Eden"と"Tomb"に分割されている。

  • Edenはオブジェクトが配置されるPage

  • Tombは、Edenが無くなった場合に昇格する予備Page

  • 空きSlotが少なくなるとGCが発生

Heapの構造については、以下の「オブジェクトの管理」あたりを

第5章 ガ-ベージコレクション

RVALUEのサイズは5word(40Byte)だが、このサイズを8wordに拡張することで、cpuのcachelineをまたぐことがなくなりcache効率が向上、拡張した領域にいろいろと詰め込むことで速度改善する、というPullRequestがある。パない。


世代別GC


  • 95%のオブジェクトは最初のGCで回収される

  • Mark & Sweep (マイナーGCもメジャーGCも)

  • Rootから到達可能なオブジェクトをmark

  • Oldオブジェクト : markされたオブジェクト

  • markされてないオブジェクトを 解放、再配置

  • メジャーGCは全オブジェクトをtravrseする (遅い)

  • マイナーGCでは、Oldオブジェクトから先の参照をmarkしない(ココが高速化のポイント)


WriteBarrier

問題点


  • マイナーGCでは、Oldオブジェクトから先の参照をtraverseしない

  • M&Sで、(old -> new) な参照があると、mark漏れが発生。生きてるオブジェクトをGCしてしまう

対策


  • (old -> new) な参照が生まれると、参照元のOldオブジェクトを覚えておく(Remember Set)

  • Remember Setに含まれるオブジェクトはM&SでRootとして扱う -> mark missを防ぐ

  • オブジェクトへの書き込み時に、(old -> new)な参照かをチェックするのがWriteBarrier


Normal/Shadyオブジェクト


  • Rubyの世界で発生したオブジェクトは、WriteBarrier保護されている(Normal Object)

  • C拡張から生成されたオブジェクトは、WriteBarrier保護されていない(Shady Object)

  • Shady Objectはoldにならない

  • つまり、マイナーGCのMark時にShady Objectから先の参照もmark対象となる

  • WriteBarrierによりC拡張側のメモリを意図せず解放しないようになっている


GCはいつ発生する?

マイナーGC


  • Heap内の空きSlotが無くなった場合 (if [# of Total slots] * 0.7 < [# of Living objects])

  • mallocしたsizeが一定量を超えた場合(後述 -> 「malloc_limit関連」)

メジャーGC


  • Oldオブジェクト数が閾値を超えた場合

  • RemberSet内のShady Object数が閾値を超えた場合


例 : Object.newからのGC発生を深掘りしてみる

object.cgc.cあたりを見てみると


  1. Object.newを呼び出す

  2. object.c : rb_class_new_instance()関数が呼ばれる

  3. object.c : rb_obj_alloc()関数を呼び出す

  4. object.c : rb_obj_alloc()関数では、allocatorとして定義されている関数を呼び出してobjectをheapに確保する

  5. object.c : allocatorの実体は、rb_define_alloc_func()で登録されたrb_class_allocate_instance()関数で、その実体はNEWOBJ_OFマクロ


  6. include/ruby/ruby.h : NEWOBJ_OFの定義はrb_newobj_of()関数)

  7. gc.c : rb_newobj_of()は単にnewobj_of()を呼び出すだけ

  8. gc.c : newobj_of()内で、heap_get_freeobj()を呼び出している。ここがObjectをHeapにallocateする場所のもよう

  9. gc.c : heap_get_freeobj()でheap_get_freeobj_from_next_freepage()を呼び出している

  10. gc.c : heap_prepare_freepage()で空きPageが取得できるまでheap_prepare_freepage()を呼ぶ

  11. gc.c : heap_increment()がFALSEを返した場合はGCが実行される模様

  12. gc.c : heap_increment()は、heap_pages_increment = 0ならFALSEを返す

  13. gc.c : heap_pages_incrementは、heap_set_increment()で設定される値。利用できるHeap内のPageの残数っぽい?

このあたりで力尽きた


GCのパラメータ

GCのパラメータはgc.cに定義してあるぽい。環境変数経由で設定する

ruby/gc.c at v2_1_1 · ruby/ruby

初期値はこのあたりに

ruby/gc.c at v2_1_1 · ruby/ruby


基本



  • RUBY_GC_HEAP_INIT_SLOTS
    (default : 10000)

最初にHeep上にAllocateするSlot数



  • RUBY_GC_HEAP_FREE_SLOTS
    (default: 4096)

GC後に最低限確保しておく空きSlot数

この値を下回ると、追加でSlotを確保しようとする

なお、総Slot数に対する空きSlot数の割合は、30%から80%以内に収まるように実装されている

ruby/gc.c at v2_1_1 · ruby/ruby



  • RUBY_GC_HEAP_GROWTH_FACTOR
    (new from 2.1)
    (default : 1.8)

Slotを確保する際の増加倍率。

Slotを増やす際には、現在のSlot数にこの倍率をかけた値まで拡張される



  • RUBY_GC_HEAP_GROWTH_MAX_SLOTS
    (new from 2.1)
    (default 0)

一度に確保するSlot数の上限。

RUBY_GC_HEAP_GROWTH_FACTORが1.0以上に設定されている場合、 HEAPが不足するたびにSlot数が増加するが、このパラメータにより一度に確保するSlot数の上限を設定できる。

ただし、defultは0に設定されており、0の場合はSlot数の上限は有効にならない。



  • RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
    (new from 2.1.1)
    (default : 2.0)

Oldオブジェクト数が一定数を超えるとメジャーGCが実行される。

(oldobject数) > (R * N)

R : この値(RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR)

N : 前回メジャーGCが終了した時点でのoldobject数

初期値では2.0なので、oldobject数が2倍になる毎にメジャーGCされることになる。


malloc_limit関連

RubyのHeap領域内のSlotにはRVALUEオブジェクトが配置される。Slotのサイズは40byteで、RVALUE構造体それ自体はそのサイズ内に収まるが、Stringオブジェクトの文字列本体などはHeap外にメモリを確保する必要がある。

このメモリの確保はruby_xmalloc()によって行われる。

VMは、ruby_xmalloc()によって確保されて未解放の領域サイズ(byte)を管理している。

この値をmalloc_increaseという。



  • RUBY_GC_MALLOC_LIMIT
    (default: 16MB)

malloc_increaseRUBY_GC_MALLOC_LIMITの値を超えた場合、マイナーGCを実行する(空きSlotの有無にかかわらず)

defaultは16MBなので、Heap外に16MB確保する毎にマイナーGCが実行されることになる。

この初期値は古いマシンを基準に設定されているので、ほとんどのRailsアプリケーションではこの値を増加させたほうがよい。

大きめに確保しておくことで、ruby_xmalloc()をトリガーとしたマイナーGCの発生頻度を押させることができる(rssの肥大とトレードオフ)

GC.statで、malloc_increaseの値を確認できる



  • RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR
    (new from 2.1)
    (default : 1.4)

2.1からは、RUBY_GC_MALLOC_LIMITの値(malloc_limit)は、malloc_increaseが閾値を超える毎に、この値を倍率として更新される。

ただし、RUBY_GC_MALLOC_LIMIT_MAXを超えて拡張することはない。



  • RUBY_GC_MALLOC_LIMIT_MAX`
    (default: 32MB)

RUBY_GC_MALLOC_LIMIT_GROWTH_FACTORによってmalloc_limitは拡張されるが、その最大値。


old_malloc_limit関連

Oldオブジェクトがruby_xmalloc()によって確保しているサイズを閾値としたメジャーGCについては、以下のパラメータで調整できる。

各パラメータの位置付けは、malloc_increaseを元にしたGCと同じ扱いである



  • RUBY_GC_OLDMALLOC_LIMIT (default : 16MB)


  • RUBY_GC_OLDMALLOC_LIMIT_MAX (default :128MB)


  • RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR (default : 1.2)


GCの状況確認 : GC.statについて

GCの実行回数などの統計情報は、GC.statで確認できる

それぞれの値の意味については、以下の資料が詳しい

が、簡単に解説してみる

GC.stat

{
:count => 162, # GCの実行回数(minor + fullの合計)
:minor_gc_count => 137, # マイナーGC実行回数
:major_gc_count => 25, # メジャーGC実行回数
:total_allocated_object => 210784578, # Heapに確保された通算のオブジェクト数
:total_freed_object => 199431871, # Heapから回収された通算のオブジェクト数
:heap_length => 75618, # Heap用に確保されたPage数
:heap_used => 43315, # 実際に使用されているPage数(Eden + Tomb)
:heap_eden_page_length => 42427, # "Eden" Page数
:heap_tomb_page_length => 888, # "Tomb" Page数
:heap_live_slot => 11352707, # 使用Slot数
:heap_free_slot => 6302456, # 空きSlot数
:heap_final_slot => 0, # finalizerを実行すべきSlot数
:heap_swept_slot => 6297729, # SweepされたSlot数(各Pageをsweepするたびにresetされる)
:heap_increment => 32303, # 未使用Page数 (:heap_used + :heap_increment = :heap_length)
:remembered_shady_object => 39172, # Remeber Set内のshady object数(メジャーGC毎にresetされる)
:remembered_shady_object_limit => 43012, # remembered_shady_objectがこの値を超えるとメジャーGCが発生する. メジャーGC毎にRUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTORを倍率として更新される
:old_object => 10920042, # Oldオブジェクト数
:old_object_limit => 20146314, # old_objectがこの値を超えるとメジャーGCが発生する. メジャーGC毎にRUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTORを倍率として更新される
:malloc_increase => 40715312, # `ruby_xmalloc()`によって確保されて未解放の領域サイズ(byte)
:malloc_limit => 50000000, # malloc_increaseがこの値を超えるとマイナーGCが発生するj
:oldmalloc_increase => 83253560, # Oldオブジェクトが`ruby_xmalloc()`で確保している領域サイズ(byte)
:oldmalloc_limit => 134217728 # oldmalloc_increaseがこの値を超えると、メジャーGCが発生する
}

gc_tracer.gemを利用すると、GC.stat()GC.latest_gc_info()の結果をGCの開始、mark&sweeepの開始/終了のタイミングでloggingできる模様。

ko1/gc_tracer


チューニング戦略

GCのチューニングはアプリケーションの特性にもよるが、メモリ使用量とスループットのトレードオフと考えてよい。

まず、malloc_limit関連で、RUBY_GC_MALLOC_LIMITは引き上げておいた方がよいだろう。

どの程度に設定するかはプロセスのrssの増加量にもよるが、手元のアプリケーション(Rails Unicorn)では64MBから128MB程度の値で、速度が安定した。

Slot数についてだが、基本的には、初期確保するSlot数(RUBY_GC_HEAP_INIT_SLOTS)を増やすことで、 立ち上がりからGCが暖まるまでの時間を短縮できる。

GC後の空きSlot数(RUBY_GC_HEAP_FREE_SLOTS)を多くしておくことで、GC後に使えるSlotが増えて、次回GC実行までのインターバルを長くし、トータルのGC実行時間を短くできる。

または、RUBY_GC_HEAP_GROWTH_FACTORの値を大きくすることで、Heapの拡大率を上げるという方法もある。

無論、これらの値を多くすることはメモリ使用量とのトレードオフである。

また、RUBY_GC_HEAP_FREE_SLOTSを多くしておくと、RUBY_GC_HEAP_GROWTH_FACTORの倍率だけSlot数が増えていくので、初期に多くのSlotを確保しておく戦略の場合は、RUBY_GC_HEAP_GROWTH_FACTORの値を下げておくのがよいかもしれない。

無論、プロセスのライフサイクルによる。

たとえば、Unicornで動くアプリケーションで、Out of Band GCと unicorn-worker-killer.gemでOomKillerを有効にしてあって、RSSを一定量超えたworkerがkillされるような設定の運用だと、RUBY_GC_HEAP_GROWTH_FACTORの倍率によってはすぐに規定量のRSSに達してすぐにworkerがkillされてしまうような事も起こりえる。

その場合にそなえて、RUBY_GC_HEAP_GROWTH_FACTORの倍率は控えめに設定してworkerの寿命を延ばして、長いスパンで見たスループットを稼ぐ、という選択肢もあり得る。

この手のpreforkなworkerで動くRailsアプリケーションなどは、アプリケーションのコードがpreloadされた状態でGC.statを確認し、使用されているslot数が、RUBY_GC_HEAP_INIT_SLOTSで確保しておく最低限のSlot数になるであろう。

バッチなど、ともかくメモリ使用量よりGCの停止時間を短くしたい場合は、RUBY_GC_HEAP_INIT_SLOTSRUBY_GC_HEAP_FREE_SLOTSは多めにしておき、RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTORも増やしておくことで、GC自体の実行回数を押さえることが出来ると予想される。

手元にある、ActiveRecordオブジェクトを大量にInstance化して利用するバッチでは、終了時に使用していたSlot数(:heap_live_slot)にRUBY_GC_HEAP_INIT_SLOTSを設定することで、約20%の速度改善を達成できた。

また、このバッチはrssを大量に(3, 4GB)ほど確保するので、RUBY_GC_MALLOC_LIMITおよびRUBY_GC_OLDMALLOC_LIMITを512MBに引き上げることで、かなりの効果を確認できた。


参考