Ruby

Ruby におけるメモリの話

食べログ AdventCalendar の25 日目 最終日の今日は @kamina_zzz が担当します。

クリスマスといえば Ruby の新バージョンということで、まあそれとは全く関係無いんですが Ruby におけるメモリの扱いについて書こうと思います。

環境は以下の通りです。


  • Ruby v2.5

  • CRuby (MRI)

よろしくおねがいします!


オブジェクトとメモリ

Ruby におけるオブジェクトは、 Ruby 内部の C 言語で登場する名前で言うところの VALUE に相当します。

オブジェクトはサイズによって内部でメモリの扱いが違うため、まずはそれを説明します。


小さいオブジェクト

VALUE の定義は以下の通りです。

typedef unsigned long VALUE;

VALUE は普段はポインタへキャストして使われますが、Ruby が特別扱いした小さいオブジェクト ( special constants ) は例外的にこの VALUE に直接埋め込むようになっています。

定義は ここに記述されています が、例えば以下のようなものが該当します。



  • true, false

  • nil


  • sizeof(VALUE)*8-1 ビットに収まる符号付き整数

これらの小さいオブジェクトは VALUE だけで実現されるため、Ruby VM は新たにメモリを確保する必要がなく、非常に高速に扱うことができます。

実際に確認してみると

$ pry -robjspace

[1] pry(main)> ObjectSpace.memsize_of(nil)
=> 0
[2] pry(main)> ObjectSpace.memsize_of(true)
=> 0
[3] pry(main)> ObjectSpace.memsize_of(0)
=> 0

と、このようにすべてメモリ使用量が 0 となっています。


普通のオブジェクト

先程 Ruby のオブジェクトは内部では VALUE に相当すると書きましたが、一般的なオブジェクトにおいて VALUE は型ごとの構造体 ( 例えば RString ) をさすポインタです。

つまり、例えば Ruby の String を扱う際、CRuby 内部では以下のような RString を指す VALUE ポインタを扱っていることになります。


ruby.h

struct RString {

struct RBasic basic;
union {
struct {
long len;
char *ptr;
union {
long capa;
VALUE shared;
} aux;
} heap;
char ary[RSTRING_EMBED_LEN_MAX + 1];
} as;
};

いわゆる普通のオブジェクトは Ruby VM が確保したヒープ上にメモリを確保して保持されます。

Ruby ではメモリを抽象化して扱うためのデータ構造が実装されており、 Heap, Page, Slot といった概念が登場します。

これらをざっくり説明すると以下のような形になっています。

heap, page, slot


  • Heap は Page をいくつももち、Ruby VM がメモリ不足を感じると Page を増やします

  • Page にはデフォルト値だと 408 個の Slot が入っており、Slot は 40 byte なので 16KB となります


RVALUE 構造体

RVALUE は要素を一つだけ持つ構造体で、 Ruby の世界におけるクラス(例えば String)とそれに対応する CRuby 内部の型 ( 例えば RString ) が 列挙されています


gc.c

typedef struct RVALUE {

union {
struct {
VALUE flags; /* always 0 for freed obj */
struct RVALUE *next;
} free;
struct RBasic basic;
struct RObject object;
struct RClass klass;
struct RFloat flonum;
struct RString string;
struct RArray array;

/* ~snip~ */

} as;
} RVALUE;


Ruby ではメモリの管理をする時にこの RVALUE を介して行うようになっています。

RVALUE の中はただの共用体なので、実体は RString だったり RArray だったりしますが、すべてサイズは 40 byte です。

新たなオブジェクトを生成する必要があれば、 Ruby は空いている Slot ( つまり RVALUE ) を探して使用します。そのため、サイズが 0 ではないオブジェクトのうち、最も小さいサイズは 40 byte となります。

$ pry -robjspace

[1] pry(main)> ObjectSpace.memsize_of("")
=> 40
[2] pry(main)> ObjectSpace.memsize_of([])
=> 40

クラスにより内部実装が違うため詳細は割愛しますが、オブジェクトが大きなデータを持つことになった場合、 Ruby VM はすでに確保した 40 byte とは別に OS から追加のメモリを確保して格納します。

$ pry -robjspace

[1] pry(main)> ObjectSpace.memsize_of([1])
=> 40
[2] pry(main)> ObjectSpace.memsize_of([1, 2, 3])
=> 40
[3] pry(main)> ObjectSpace.memsize_of([1, 2, 3, 4, 5])
=> 80
[4] pry(main)> ObjectSpace.memsize_of([1, 2, 3, 4, 5, 6, 7])
=> 96


GC ( Garbage Collection )

GC とは直訳して「ゴミ集め」となるように、簡単に言えばゴミとなったオブジェクトを集めて捨て、メモリを有効活用するための仕組みです。

これによりエンジニアはオブジェクトが欲しくなったら作るだけ作っておけばあとは Ruby が勝手に掃除してくれます。

つまりは mallocfree をエンジニアが意識しなくても良いのです。

それにもかかわらずメモリについて記事を書いているわけですが。

Ruby の GC では Mark & Sweep というアルゴリズムを採用しています。

このアルゴリズムは簡単に言うと

1. Mark フェーズ: 使用中のオブジェクトから参照されているオブジェクトにフラグを付ける

mark phase

2. Sweep フェーズ: どこからも参照のない ( = 使われていない ) オブジェクトを解放する

sweep phase

というものです。

例えば、大きな String のオブジェクトがあったとします。

このとき RString の予め確保されていた 40 byte に収まらない場合、外部からメモリを確保して文字列を格納し、 RString にはそのポインタのみを保持します。

この String オブジェクトが不要となった場合、Slot ( RVALUE ) の実体は free となり、外部からメモリを確保して保持しておいた実体の文字列はどこからも参照されなくなります。このようなオブジェクトが GC によって Mark されず Sweep フェーズにて解放されます。

素朴な Mark & Sweep の実装だと GC の処理中にはプログラム本体の処理が行えないなどの問題がありますが、Ruby では近年のアップデートにて解決とまではいかなくとも大幅な改善がされています。

気になる方は「Lazy Sweep」「BitmapMarking」「RGenGC」などと調べてみると面白いと思います。


省メモリによる高速化戦略

メモリにまつわる低速化の代表例として以下のものが挙げられます。


  • GC の頻度増大および負荷増大

  • メモリの確保 ( malloc ) にかかる時間の増大

つまり GC を少なく、メモリ確保も極力させないようなプログラムの記述を目指していくことになります。


GC Tuning

Ruby には GC の挙動を制御するための環境変数がいくつか定義されています

アプリケーションによって「メモリの使用量」や「オブジェクトの平均寿命」などの傾向が違うため、その違いを鑑みた GC の実行戦略を立てることができます。

例えば RUBY_GC_HEAP_INIT_SLOTS という変数がありますがこれは「起動時に確保されるスロット数」で、デフォルト値で 10000 となっています。この値は一般的な Rails アプリケーションにとっては少なすぎるはずです。Rails 起動までに実行された GC の数などを参考にしてチューニングすると良いでしょう。


破壊的メソッドの使用

これはオブジェクトの生成数を抑え、Slot 外部からのメモリ確保や Page 数増加による malloc の実行回数を減らすことが主目的です。

加えて不要となるオブジェクトを減らすこともでき、GC のコストも低減できます。

例えば sort -> sort!, chomp -> chomp! のように代替することでオブジェクトの生成を防ぎ、同一の Slot を使用し続けるように促します。

いつでも使えるわけではないため頭の隅に入れておき、保守性に影響が無い程度に使っていくと良いと思います。


jemalloc

Ruby ではバイナリのビルド時に何も指定しないと glibc による malloc 実装でビルドされます。

バージョン 2.2 以降の Ruby には glibc ではなく jemalloc を有効化してビルドするオプションが追加されました。

./configure --with-jemalloc

glibc と jemalloc の違いについて詳細は省きますが、例えばメモリ割り当ての最小サイズに違いがあります。

jemalloc に変更するだけで省メモリ化したという報告もありますので、アプリケーションの特性にうまく噛み合う場合は非常に有効となりそうです。

また、メモリ確保にかかる時間についてもアプリケーションによっては 10% - 12% 改善されるとの報告もあります。

それぞれの Allocator の特性と自分のアプリケーションの特性を照らし合わせて判断できれば理想的でしょう ( もしくは canary release をしてみてデータを集めてみても良いかもしれません )。

jemalloc による改善が非常に大きいため、 Ruby のデフォルトにすべきではないかという提案もされています


おわりに

長くなってしまいましたがざっくり Ruby におけるメモリについて整理してみました。

間違いなどありましたらコメントなどで教えていただければ幸いです。

ありがとうございました!


ref