Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Malloc Can Double Multi-threaded Ruby Program Memory Usage 原文公開日: 2017/12/04 著者: Nate Berkopec — 『Complete Guide to Rails Performance』の著者です。 「スレッド単位メモリアリーナ」(per-thread memory arena)は定訳が見当たらないため仮訳を当てています。 楽しい画像はすべて英語記事からの引用です。 Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳) 概要: メモリ断片化は測定や診断が困難ですが、驚くほど簡単に修正できることもあります。マルチスレッドのCRubyプログラム(mallocのスレッド単位メモリアリーナ)におけるメモリ断片化の原因を追ってみましょう。本記事のボリュームは3343語、20分程度です。 シンプルなときはマジこのぐらいシンプル 単純な設定変更だけで問題を完全に解決できることはめったにありません。 私の顧客で、Sidekiqプロセスが大量のメモリを消費していたことがありました(1プロセスあたり1 GB程度)。開始当初の各プロセスは300MB程度でしたが、時間の経過とともにじわじわと肥大化してほぼギガバイトレベルにまで達したところで落ち着き始めました。 私は顧客にMALLOC_ARENA_MAXというたった1つの環境変数の変更を依頼しました。「2に設定してください」と。 プロセス再起動後、「じわじわ肥大化」現象はピタリと止みました。プロセスのメモリ使用量は以前の半分程度(512 MBぐらい)に落ち着いたのです。 「うっそぴょーん」 実際はそんな単純な話ではありません。フリーランチは存在しませんが、ほぼ無料(10円ランチ程度)だったと言えなくもないぐらいでした。 ここまで読んでこの「魔法の」環境変数をアプリ環境にコピペして回る前に、「この方法にはいくつもの欠点がある」ということを知っておいてください。この方法で解決される問題のことで今のあなたが困っているとも限りません。銀の弾丸はないのです。 Rubyがメモリ使用量の少ない言語であることは、意外に知られていません。しかしRailsアプリの多くは1プロセスあたりのメモリ使用量が1 GBに達する問題に悩まされています。これはJavaレベルに匹敵しつつあります。Rubyのバックグラウンドジョブプロセッサとして有名なSidekiqのプロセスも、同程度かそれより巨大になることがあります。理由はいろいろありますが、特に、断片化の診断とデバッグが極端に難しいこともそのひとつです。 Rubyメモリは対数的に肥大化するのが典型的 この問題は、速度の低下や、Rubyプロセスの不気味なメモリ肥大化という形で顕在化します。そしてよくメモリリークと間違えられます。しかしメモリリークは直線的に増加しますが、断片化によるメモリの肥大化は対数的に増加します。 普通、Rubyプログラムのメモリリークの原因はC拡張のバグによるものです。たとえば、markdownパーサーで呼び出しのたびに10 KBずつリークすると、markdownパーサーの呼び出し頻度は一定になる傾向があるので、メモリ使用量は直線的に青天井で増加し続けます。 メモリ断片化は対数的なメモリ肥大化の原因になります。肥大化は長い曲線を描いて、見えないどこかの上限値に向かいます。メモリ断片化はあらゆるRubyプロセスである程度発生します。これはRubyのメモリ管理方法による、避けがたい結果です。 特に、Rubyではオブジェクトをメモリ内で移動できません。もし移動したら、Rubyオブジェクトへのポインタを持つC言語拡張が残らず破損するかもしれません。オブジェクトをメモリ内で移動できないのだとしたら、断片化は避けられません。これはRubyに限らず、多くのCプログラムで共通の問題です。 顧客の実際のグラフより: 断片化はこのような経過をたどります。`MALLOC_ARENA_MAX`を`2`にすると激減していることにご注目ください。 しかしRubyプログラムのメモリ使用量は、断片化によって通常の「倍」に、場合によっては4倍以上に達することすらあるのです! Rubyプログラマーはメモリを意識することに慣れていません。特にmallocレベルではなかなか意識できません。Ruby言語全体が、抽象的なメモリをプログラマーが意識しないで済むように設計されているのですから、もちろんそれ自体は別に問題ありません。しかしRubyはメモリ安全性を保証できるにもかかわらず、完全なメモリ抽象化を提供できていません。メモリを完全に無視するわけにはいかないのです。というのも、Rubyプログラマーはコンピュータ上のメモリの動作についてそれほど経験を積んでいないので、いざ問題が発生すると、どこからデバッグを始めたらよいのか皆目見当がつかなくなることも多く、Rubyのような動的インタプリタ言語の本質的な機能だから仕方がないと諦めることもあります。 「お姫様が4つの敷布団(メモリ抽象化)をめくると、何とその下が断片化し始めているではありませんか」 さらに厄介なのは、メモリが4つの異なる層によって抽象化され、Rubyistから見えなくなっていることです。第1層はRubyの仮想マシン(VM)そのものです。VMは独自の内部構造やメモリトラッキング機能を備えています(これはObjectSpaceと呼ばれることもあります)。第2層はアロケータです。この振る舞いは、用いる特定の実装によって大きく異なります。第3層はOSです。ここでは実際の物理メモリアドレスを仮想メモリアドレスに抽象化します。この抽象化はカーネルごとに大きく異なっています。たとえばMachの抽象化はLinuxとかなり異なります。最後の第4層は実際のハードウェアそのものです。ここでは、アクセスの多いデータが、頻繁にアクセスしやすい「ホット」な場所からなるべく移動しないようにするためにさまざまな戦略を用います。ときには、translation lookaside buffer(TLB)のような特殊なCPU部品まで関わっていることがあります。 これらによって、Rubyistによるメモリ断片化問題の対処が非常に困難になっています。この問題は、仮想マシンやアロケータのレベルで一般的に発生しますが、ここはRubyistの95%にとって馴染みのない部分でもあります。 断片化には避けようのないものもありますが、Rubyプロセスのメモリ使用量が倍増するほど悪化する原因でもあります。今起きているのが避けられない断片化ではなく、メモリ使用量が倍増する断片化かどうかをどうやって知ればよいでしょうか。なお私は、PumaやPassenger Enterprise上で動作するWebアプリや、SidekiqやSucker PunchなどのマルチスレッドジョブプロセッサのマルチスレッドRubyアプリに影響するメモリ断片化の原因について論文を1本書いたことがあります。 glibc malloc内のスレッド単位メモリアリーナ この問題は、いずれも標準glibcの「スレッド単位メモリアリーナ(per-thread memory arena)」と呼ばれるmalloc実装の特定の機能に集約されます。 理由を理解いただくためには、CRubyのガベージコレクション(GC)が驚くほど高速に実行される様子について説明する必要があります。 Aaron PattersonによるObjectSpaceのビジュアル表示。ピクセル1個が1つのRVALUEを表す。新しいのが緑、古いのが赤。詳しくはheapfragを参照。 すべてのオブジェクトは、ObjectSpace内にエントリを1つ持ちます。ObjectSpaceは、プロセス内で動作しているあらゆるRubyオブジェクトのエントリを保持する巨大なリストです。リストの項目はRVALUEの形を取ります。RVALUEはオブジェクトの基本データの一部を保持する40バイトのC言語struct(構造体)です。構造体の正確な中身は、どのクラスのオブジェクトかによって変わります。たとえば、”hello”のような非常に短いStringの場合、文字データを実際に含むビットはRVALUE内に直接埋め込まれます。しかしRVALUEは40バイトしかないので、文字列が23バイト以上になると、RVALUEは、そのオブジェクトデータが実際に配置されるメモリの場所(つまりRVALUEの外)を参照する生のポインタだけを保持します。 複数のRVALUEがObjectSpace内で編成されて16 KBの「ページ」になります。1つのページには約408個のRVALUEが含まれます。 この個数は、どのRubyプロセスについてもGC::INTERNAL_CONSTANTS定数で確認できます。 GC::INTERNAL_CONSTANTS => { :RVALUE_SIZE=>40, :HEAP_PAGE_OBJ_LIMIT=>408, # … } 長い文字列を作成すると(たとえば1000文字のHTTPレスポンス)、次のようになります ObjectSpaceリストにRVALUEを1つ追加します。ObjectSpaceの空きスロットが不足すると、malloc(16384)を呼び出してリストをヒープページ1つ分増やします。 malloc(1000)を呼び出して、メモリ上1の1000バイトを指すアドレスを1つ受け取ります。 このmalloc呼び出しにご注目ください。私たちが行おうとしているのは、特定サイズのメモリ領域を(場所は問わずに)求めているだけです。実際には、mallocの隣接については定義されていないのです。つまり、メモリ上で実際に配置される場所については何の保証もないということになります。ということは、断片化(根本的にはメモリの場所の問題です)とは、Ruby VMから見ればアロケータの問題だということです2。 Ruby自体のObjectSpaceの断片化はある意味で測定可能です。GCモジュールのメソッドGC.statは、現在のメモリやGCのステートに関する情報を豊富に提供してくれます。情報がやや多い上にドキュメントも不十分ですが、次のようなハッシュを出力します。 GC.stat => { :count=>12, :heap_allocated_pages=>91, :heap_sorted_length=>91, # … 他にも多数のキー … } このハッシュの中でご注目いただきたいのは、GC.stat[:heap_live_slots]とGC.stat[:heap_eden_pages]の2つのキーです。 :heap_live_slotsは、生きている(=解放とマーキングされていない)RVALUE構造体が現在専有しているObjectSpace内のスロット数を表します。これはざっくり「現在生きているRubyオブジェクト」の数とみなせます。 edenのヒープ :heap_eden_pagesは、生きているスロットを1つ以上含んでいるObjectSpaceページの個数を表します。生きているスロットを1つ以上含むObjectSpaceページは、「edenページ」と呼ばれます。生きているスロットを1つも含まないObjectSpaceページは「tombページ」と呼ばれます。tombページはOSに返却される可能性があるので、両者の違いはGC側にとって重要です。また、GCが新しいオブジェクトを最初に置くのはedenページであり、edenページに空きがない場合に初めてtombページに置きます。これによって断片化を軽減しています。 生きているスロット数を全edenページ内のスロット数で割ると、ObjectSpaceで現在発生している断片化の度合いを求められます。私が新しいirbプロセスで取得した例をご覧ください。 5.times { GC.start } GC.stat[:heap_live_slots] # 24508 GC.stat[:heap_eden_pages] # 83 GC::INTERNAL_CONSTANTS[:HEAP_PAGE_OBJ_LIMIT] # 408 # live_slots / (eden_pages * slots_per_page) # 24508 / (83 * 408) = 72.3% 私のedenページスロットのうち28%は現在空いています。空きスロットの割合が多いということは、ObjectSpaceのRVALUEが本来よりずっと多くのヒープページにばらまかれているということになります(もし見ることができればですが)。これは内部メモリの断片化の一種です。 Ruby VMの内部断片化を知るもうひとつの測定値はGC.stat[:heap_sorted_length]です。このキーは1つのヒープの「長さ」を表します。ObjectSpaceが3つあり、私が真ん中の2番目をfreeしたとすると、残るヒープページは2つだけになります。しかしヒープページはメモリ内を移動できないので、そのヒープの「長さ」(本質的にはそのヒープページのインデックスの最大値)は3のまま変わりません。 断片化してるけどとってもおいしそうなヒープ GC.stat[:heap_eden_pages]をGC.stat[:heap_sorted_length]で割ると、ObjectSpaceページレベルの内部断片化の測定値を得られます。この割合が低いと、ObjectSpaceリストのあちこちにヒープページ大の穴が開いていることになります。 これらの測定値も興味深いのですが、ほとんどのメモリ断片化(およびアロケーション)はObjectSpaceの中では起きていないのです。断片化が発生するのは、1個のRVALUEに収まりきれないオブジェクトに空き(メモリ)を割り当てるときです。Aaron PattersonとSam Saffronによる実験結果によると、ほとんどがこれに該当することが判明しました。典型的なRailsアプリのメモリ使用量の50%〜80%は、たかが数バイトより大きなオブジェクトに空きメモリを割り当てるmalloc呼び出しによって占められています。 Well this sucks. Looks like only 15% of the heap in a basic Rails app is managed by the GC. 85% is just mallocs pic.twitter.com/sPbtAq4g8j — Aaron Patterson (@tenderlove) June 28, 2017 Aaronの言う「managed by the GC」は、実際には「ObjectSpaceリストの内部で」のことです。 それでは「スレッド単位メモリアリーナ」の話題に移りましょう。 スレッド単位メモリアリーナはglibc 2.10(現在はarena.cにある)で導入された最適化であり、メモリアクセス時のスレッド間競合を軽減するよう設計されました。 アロケータの素朴な基本設計では、メインアリーナの1つのメモリチャンクを要求できるのは一度に1つのスレッドに限定されます。これによってメモリの同じチャンクを誤って2つのスレッドが取得することがないようにできます。そのような状況が発生すると、かなりやな感じのマルチスレッドバグの原因になります。しかしスレッドを多数持つプログラムではロックの奪い合いが多数発生して速度が低下することがあります。すべてのスレッドからのすべてのメモリアクセスはこのロックを必ず経由するので、ここがボトルネックになることがおわかりいただけるかと思います。 パフォーマンスへの影響が甚大なため、アロケータの設計ではこのロック機構を撤廃するために多くの努力が注ぎ込まれました。ロックを行わないアロケータすらいくつか開発されたほどです。 スレッド単位メモリアリーナの実装は、次の処理におけるロックの奪い合いを軽減します(Siddhesh Poyarekarの記事を言い換えたものです)。 … Continue reading Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)