🦖

Part2 「Rubyのメモリ仕様 -ブラックボックスを覗く-」

2022/04/10に公開約6,000字

3回に分けて記事を作成する。
本記事はそのPart2である。

Part1 「Rubyのメモリ肥大化はfragmentation(断片化)が原因(かもしれない)」
Part2 「Rubyのメモリ仕様 -ブラックボックスを覗く-」
Part3 「jemallocがRubyアプリケーションのメモリ利用効率を向上させるのか...?」

目次

  • 浅く理解するRubyのメモリ仕様
  • 世代別インクリメンタルGC
  • Ruby2.7と3.0のCompaction
  • 原因はfragmentation(断片化)ではない..?
  • まとめと謝罪

浅く理解するRubyのメモリ仕様

Part1読んだ読者の中には(もしかしたら読者は一人も存在しないかもしれないが...)、mallocやfragmentationといった単語に馴染みがない方も居るかもしれない。そこで簡単にではあるが、Rubyのメモリ仕様について解説しておこう。なおこの章は、Hongli Lai氏の「What causes Ruby memory bloat?」の内容を大きく参考にした。またLai氏は記事において、Rubyのメモリ割当について興味深い指摘をしているのだが、その点については後で取り上げるものとする。まずはRubyのメモリ仕様の基本的な部分をまとめてみよう。

メモリ割当

Rubyのメモリ割当は、3層に分けて考える。

  1. Rubyインタープリタ
  2. メモリアロケータ
  3. kernel

1. Rubyインタープリタ

Heapには、複数のPageが存在している。 Pageの内部は、Slotという単位で区切られている。 1つのスロットを1つのオブジェクトが占有する。 String, Hash Array等、どんなオブジェクトであっても最低1つのスロットを占有する。Slotのサイズは40byteだが、当然ここに収まらいオブジェクトも存在する。例えば1MBのStringの場合、複数のSlotを用いて格納することになる。

2. メモリアロケーター

OSのメモリの確保と開放を行うgblicの機能である。メモリアロケータは、OS Heap(RubyのHeapと区別するために、ここではOS Heapと呼んでいる)からRubyのHeaptの為にメモリを割当てる。また、この時確保されるメモリはRubyのHeapとは違って、割当てられるサイズがそれぞれで異なる。

3. Kernel

OS Heapは、Kernelから割当てられたメモリである。Kernelからは4KB単位でメモリを割当てる事になるのだが、最小単位が4KBであることは必要以上のメモリの割当てを引き起こす可能性がある。(例えば必要なメモリは10KBだが、実際に割当てられるメモリは12KB(4KB✕3unit)である。)またメモリアロケータからのKernelへのアクセスは、パフォーマンスを悪化させるため、最小限に抑えられるよう設計されている。

割当てられたメモリをここではOS Pageと呼んでおく。全体の流れとしては、Kernelからメモリアロケータがメモリ(OS Heap)を確保し、それがRubyのHeapに割当てられる。なおここでのKernelとはLinux kernelを指しており、RubyのKernelモジュールとは異なる。

fragmentation(断片化)の原因

実はfragmentation(断片化)は、Rubyインタープリタとメモリアロケータの2層双方にその発生の原因が存在している。

  1. Rubyインタープリタ
    RubyのGCが不要になったSlotを開放してくれるのだが、Slotは不要になったものから開放されるの為、各Pageには”穴”が出来てしまう。Page内で利用されているメモリが一部であっても、そのPageは開放されずにメモリを占有する。これが必要以上のメモリの使用に繋がる。

  2. メモリアロケータ
    メモリアロケータが確保するOS Pageのサイズは様々である。これがOS Heapを増やす原因になる。

12KBのOS Heapが、3KBのOS Page✕ 1、4KBのOS Page✕2、1KBのOS Pageの並び順で構成されているとする。ここで、3KBのOS Pageと1KBのOS Pageが開放されたとしよう。その後に 4KBのOS Pageが必要になっても、入り込むことが出来ないのだ...。この場合、新たにOS Heapが作成され、メモリ使用量は肥大化する。

世代別インクリメンタルGC

世代別インクリメンタルGC

ガーベージコレクション(以下GC)は、必要のなくなったメモリを自動的に検出し解放してくれる機能である。従来のRubyのGCは、マーク&スイープという方式を採用していた。この方式には、GCの処理が遅いという課題があった。

マーク&スイープは、オブジェクト領域全体を最低でも一度なめる必要があるという弱点があった。
Rubyソースコード完全解説 第5章 ガ-ベージコレクション」より

そして、Ruby2.1で世代別GC、2.2でインクリメンタルGCが導入された。世代別GC、インクリメンタルGCには、ライトバリアという、オブジェクトへの書き込みを検知する手法を実装する必要があったのだが、従来のRubyにはライトバリアの実装がなかった。ここで導入されたのは、制限を加えた「Restricted Genrational GC(RGenGC)」及び「Restricted incremental GC(RincGC)」である。
(参考:「Rubyにおけるライトバリアのないオブジェクトを考慮した世代別インクリメンタルGCの実装」

世代別GC

新しいオブジェクトをGCの対象とするマイナーGC、全てのオブジェクトを対象とするメジャーGCを使い分ける。マイナーGCを高頻度で行い、必要な時にメジャーGCを用いる。常に全てのオブジェクトを対象とするマーク&スイープよりも効率的である。

インクリメンタルGC

メジャーGCによる停止時間を短くするための手法。GC 処理を分割し、プログラムと交互に実行することで、プログラムの長時間の停止を回避することが可能になる。

Ruby2.7と3.0のCompaction

前回の記事でRubyのメモリ仕様の肥大化、その原因となっているfragmentation(断片化)を取り上げた。虫食いのようになったメモリを整理するのがCompactionと呼ばれる仕組みだ。Ruby2.7で導入されたGC.compactがこれを実現する。ただし、GC.compactはプログラマが任意のタイミングで呼び出すメソッドである。実践的にGC.compactを使用して、Compactionを実現するのはハードルが高そうだ。Ruby3.0では、GC.auto_compact = trueが追加され、Compactionが自動で設定できるようになった。然乍、Compactionの実行はパフォーマンスとのトレードオフになる。デフォルトでは設定がfalseになっていることも踏まえると、Compactionの実装はまだ取り入れるタイミングではないように思える。さらにCompactionは、Rubyインタープリタにおけるテーマだ。メモリアロケータに起因するfragmentation(断片化)を解決しない。実はメモリアロケータの方がメモリを肥大化させる主要因かもしれないという提起がある。そうであるならば、RubyインタープリタレベルでのCompactionはRubyアプリケーションのメモリ肥大化を抑制できない。

原因はfragmentation(断片化)ではない..?

先程Rubyのメモリ割当を3層に分類して紹介した。そしてfragmentation(断片化)は、Rubyインタープリタとメモリアロケータの2層でそれぞれ発生すると述べた。Hongli Lai氏の「What causes Ruby memory bloat?」の内容に改めて立ち返る。何がRubyのメモリを肥大化させるのだろうか...?

Lai氏の計測によれば、Rubyインタープリタよりも、メモリアロケータが使用するメモリ使用量の方が圧倒的に大きい。(メモリ全体の使用量が230MBに対し、Rubyインタープリタの使用量は僅か7MBだ...)

…this … is… insane! (同記事より抜粋)

GC.compaction及びGC.auto_compact = trueでは、メモリアロケータレベルでのfragmentation(断片化)を解決しない。そもそも、Lai氏はメモリ肥大化の要因はfragmentation(断片化)ではないという主張を展開している。ここまで述べた内容が全てひっくり返る話だ...

彼の主張ではメモリ肥大化の要因となるのは、fragmentation(断片化)ではなく、Kernelまで使用済みのメモリ領域が返却されていないことにある。

main problem is the fact that the memory allocator doesn't like to free memory back to the kernel.
主要な問題はメモリアロケータがKernelにメモリを返却したがらないことだ。 (同記事より抜粋)

もし彼の検証が正しければ、メモリ使用量が上昇した時に行うべきは、malloc_trim()によるメモリの開放だ。然乍、まだ不明確な部分が多い為、彼の主張は1つの可能性として留めておこう。

ところで何故Kernelにメモリが返却されないという現象が発生するのだろうか。Rubyではfree()されたメモリがOSまで返却されるには条件がある。OS Heapの先頭のメモリが解放済みである場合にのみ、先頭を切り詰める形でメモリが開放されるというものだ。Hongli氏の図をよく見ると、先頭に使用中のメモリ(赤いアリーナ)が滞留しているのが分かるだろうか。彼の検証結果はもしかしたら、先程の条件で説明できるかもしれない。が、これはあくまで筆者の推測にすぎない。

まとめと謝罪

Part2では、Rubyのメモリ仕様、従来より高速なGCを実現する世代別インクリメンタルGC、Ruby2.7、3.0のCompactionを取り上げた。世代別インクリメンタルGCとCompactionにより、Rubyインタプリタレベルでのメモリ解放は確実に高速・効率的になっている。
一方で実際のメモリ肥大化に大きく影響するのは、メモリアロケータレベルだとの指摘がある。さらに真に問題なのはfragmentation(断片化)ではなく、メモリがKernelへ返却されないことにあるかもしれない。
結論筆者は現時点でこれらの問題について何ら解決に至っていない。実際のRubyアプリケーションをもとにした豊富な検証(データ)が必要だろう。筆者は普段メモリ管理をRubyのGCに全て頼り切りであって、RubyとOSのメモリ管理はまさに筆者にとってブラックボックスなのだ。この3部構成の記事は、重い腰を持ち上げてそのブラックボックスの中を除いてみた記録である。分かりにくい部分が多いことをお詫びしたい。

Part3 「jemallocがRubyアプリケーションのメモリ利用効率を向上させるのか...?」

その他参考

Discussion

ログインするとコメントできます