Ruby は String をメモリ上でどのように扱っているのか?
2024年07月17日 水曜日
CONTENTS
先日、 Ruby (CRuby) が String をメモリ上でどのように扱っているのか気になって調べていました。そこで String の構造体やメモリの扱いとその変遷が興味深いと思ったので、ちょっとまとめてみました。
Ruby のオブジェクトのデータ構造
まず前提知識として、そもそも Ruby が C の世界でオブジェクトをどのようなデータ構造で持っているかをざっくりおさらいします。
VALUE 型
Ruby の内部ではオブジェクトを VALUE 型という型の変数で取り扱っています。
// include/ruby/internal/value.h typedef unsigned long VALUE;
宣言の通り VALUE 型の実体は unsigned long ですが、その値というかデータの保持の仕方には大きく 2 種類あります。
- VALUE が即値としてオブジェクトの値を保持するパターン
- 以下のクラスのオブジェクト
- nil, true, false
- Symbol
- Integer (値の小さいもの (昔の Fixnum に相当))
- 以下のクラスのオブジェクト
- VALUE がポインターとしてオブジェクトの実体の構造体を指し示すパターン
- 上記以外のクラスのオブジェクト
後者のクラスのオブジェクトは、「RVALUE」という構造体にデータを保持しています。
RVALUE
RVALUE 構造体の中は共用体となっていて、主要な組み込みクラスに対応する構造体がそのメンバーとして列挙されています。
// gc.c typedef struct RVALUE { union { struct { VALUE flags; /* always 0 for freed obj */ struct RVALUE *next; } free; struct RMoved moved; struct RBasic basic; struct RObject object; struct RClass klass; struct RFloat flonum; struct RString string; struct RArray array; struct RRegexp regexp; struct RHash hash; struct RData data; struct RTypedData typeddata; struct RStruct rstruct; struct RBignum bignum; struct RFile file; struct RMatch match; struct RRational rational; struct RComplex complex; struct RSymbol symbol; // snip... } as; // snip... } RVALUE;
各構造体のサイズはどれも 40 バイトに収まるように統一されています。この構造体に収まらないデータを持つ必要がある場合は別途データ領域を malloc して確保することになります。
これらの構造体は、生成・破棄するたびに malloc & free するわけではなく、ヒープと呼ばれる領域にある程度まとまった単位でガバッと malloc して、あとは Ruby インタープリター内でよしなにやりくりして、足りなくなったらまたまとめて確保する、というようなことをしています。それにより malloc & free のオーバーヘッドが低減されます。
Heap Slot Slot Slot Slot +---+ +--------------------------------------------+ | ------>| RVALUE | RVALUE | ...... | RVALUE | RVALUE | Page | | +--------------------------------------------+ +---+ +--------------------------------------------+ | ------>| RVALUE | RVALUE | ...... | RVALUE | RVALUE | Page | | +--------------------------------------------+ +---+ +--------------------------------------------+ | ------>| RVALUE | RVALUE | ...... | RVALUE | RVALUE | Page | | +--------------------------------------------+ +---+ | | |<- 40b->| | | |<------------------ 16kb ------------------>| +---+ | |
また、サイズが 40 バイト単位で統一されていることでフラグメントも起こりにくくなります。
RVALUE に属する構造体の具体的な中身の一例として RObject を下に示します。
struct RBasic { VALUE flags; const VALUE klass; }; struct RObject { struct RBasic basic; union { struct { VALUE *ivptr; struct rb_id_table *iv_index_tbl; } heap; VALUE ary[1]; } as; };
RVALUE のどの構造体も先頭は RBasic となっていて、ここにはインスタンスの各種フラグを 8 バイトで保持する flags と、そのインスタンスが属しているクラスを表す klass がセットされています。
その後ろはオブジェクトのクラスに応じた各種データが格納されます。
RString 構造体
それでは、本題の String について見ていきます。
String は C の世界では RString という構造体で取り扱っています。
Ruby 1.8 まで
まずは昔話から。 Ruby 1.8 までの RString 構造体はこんな感じでした。
struct RString { struct RBasic basic; long len; char *ptr; union { long capa; VALUE shared; } aux; };
文字列データは長さが決まっていないため、固定長の構造体には収めることはできないということで、文字列データを別途 malloc して確保しています。
そして、それを ptr にセットし、文字列長は len にセットします。
さらにその後ろには共用体 aux というのがありますが、これが何なのか気になります。
aux.capa で文字列データ領域を余分に確保しておく
この aux.capa は malloc で確保した文字列データ領域のサイズです。(※ ただし、文字列終端の NUL の分は勘定に含みません)
RString (capa) +---+---+---+---+---+---+---+---+ | RBasic | + + | | +---+---+---+---+---+---+---+---+ | len | +---+---+---+---+---+---+---+---+ +-----------------+---+--------+-----+ | ptr ----->| C string NUL| (NUL)| +---+---+---+---+---+---+---+---+ +-----------------+---+--------+-----+ | aux.capa | <------ len ------> +---+---+---+---+---+---+---+---+ <---------- aux.capa ---------->
文字列が immutable (値が不変) であれば len だけでも十分なように思いますが、 Ruby の String のように mutable (値が可変) だと、 len よりも余分にメモリを確保しておくと都合が良くなることがあります。
例えば、下記のように部分文字列の追記を何回も繰り返して文字列を組み立てるようなケースを考えます。
def escape_text(source) result = "" source.each_line do |line| result << escape_special_characters(line) end result end
ナイーブな実装を考えてみると、元の文字列に対して部分文字列を追記して変更するたびに realloc することになるかと思いますが、追記するたびにそれを繰り返し行うのはパフォーマンスが悪そうです。追記を繰り返すことがわかっているのであれば、それを見越してある程度大雑把な単位で realloc した方がパフォーマンスが良くなります。
aux.capa の処理を実際に見てみる
これを実際に Ruby がどのように処理しているか、下記のようなライブラリを使って RString 構造体の中身を見てみることにします。なお、古いバージョンの Ruby は入手が難しいので、代わりに Ruby 3.1 以降を使います。 RubyInline が必要なのでインストールしておきます。
# inspect_rstring.rb raise NotImplementedError, 'Ruby 3.1.0 required' if RUBY_VERSION < '3.1.0' warn 'Unknown to work with Ruby 3.4.0 or higher' if RUBY_VERSION >= '3.4.0' require 'inline' class String def super_inspect self.class.superclass.instance_method(:inspect).bind(self).call end inline do |builder| builder.include '<stdio.h>' builder.include '"ruby/version.h"' builder.add_compile_flags '-Wall' builder.c_raw <<~CODE VALUE inspect_rstring(int argc, VALUE *argv, VALUE self) { FILE *o = stdout; if (argc == 1) { /* Output Title */ if (TYPE(argv[0]) != T_STRING) rb_raise(rb_eTypeError, "Title Not String"); fprintf(o, "%s\\n", RSTRING_PTR(argv[0])); } bool str_embed = ! (RBASIC(self)->flags & RSTRING_NOEMBED); bool str_shared = RBASIC(self)->flags & ELTS_SHARED; struct RString *rstring = RSTRING(self); fprintf(o, "VALUE: 0x%lx\\n", self); fprintf(o, "OBJ_FROZEN: %d\\n", OBJ_FROZEN(self)); fprintf(o, "STR_EMBED_P: %d\\n", str_embed); fprintf(o, "STR_SHARED_P: %d\\n", str_shared); //fprintf(o, "RSTRING_PTR: %p\\n", RSTRING_PTR(self)); //fprintf(o, "RSTRING_LEN: %ld\\n", RSTRING_LEN(self)); #if RUBY_API_VERSION_MINOR >= 3 fprintf(o, "rstring->len: %ld\\n", rstring->len); #endif if (str_embed) { #if RUBY_API_VERSION_MINOR < 3 #if USE_RVARGC fprintf(o, "rstring->as.embed.len: %ld\\n", rstring->as.embed.len); #endif #endif fprintf(o, "rstring->as.embed.ary: %p\\n", rstring->as.embed.ary); //fprintf(o, "rstring->as.embed.ary: \\"%s\\"\\n", rstring->as.embed.ary); } else { #if RUBY_API_VERSION_MINOR < 3 fprintf(o, "rstring->as.heap.len: %ld\\n", rstring->as.heap.len); #endif fprintf(o, "rstring->as.heap.ptr: %p\\n", rstring->as.heap.ptr); if (str_shared) { fprintf(o, "rstring->as.heap.aux.shared: 0x%lx\\n", rstring->as.heap.aux.shared); } else { fprintf(o, "rstring->as.heap.aux.capa: %ld\\n", rstring->as.heap.aux.capa); } } fprintf(o, "\\n"); fflush(o); return Qnil; } CODE end end module Kernel inline do |builder| builder.add_compile_flags '-Wall' # XXX: DANGER!!! builder.c_raw <<~CODE VALUE draw_object_by_value(int argc, VALUE *argv, VALUE _kernel) { if (argc != 1) return Qnil; unsigned long value = (VALUE)NUM2ULONG(argv[0]); return (VALUE)value; } CODE end end
※ 非公開メンバーや API を使っていて危険なのでマネしないでください。
早速 irb で実行してみます。
% RBENV_VERSION=3.1.6 irb -I. -rinspect_rstring irb(main):001> s = "*" * 1024; irb(main):002> s.inspect_rstring; VALUE: 0x2b3dd68ace58 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 1024 rstring->as.heap.ptr: 0x2b3dd1c5ab00 rstring->as.heap.aux.capa: 1024 irb(main):003> s << "hoge"; irb(main):004> s.inspect_rstring; VALUE: 0x2b3dd68ace58 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 1028 rstring->as.heap.ptr: 0x2b3dd6162200 rstring->as.heap.aux.capa: 2049 irb(main):005> s << "fuga"; irb(main):006> s.inspect_rstring; VALUE: 0x2b3dd68ace58 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 1032 rstring->as.heap.ptr: 0x2b3dd6162200 rstring->as.heap.aux.capa: 2049 irb(main):007>
Ruby 1.8 とは RString 構造体が若干異なってはいます (詳しくは後述します) が、ひとまず細かいことは気にせず、ここでは len, ptr, aux.capa の値に着目して見てみます。
最初 1024 バイトの文字列をセットした直後は、 len も aux.capa も文字列長と同じ 1024 となっています。
次に、ここに「”hoge”」を追記したところ、 ptr が変化しており、新たな文字列領域が確保されたことがわかります。また len の文字列長に比べ aux.capa は元の約二倍と大幅に増えています。
しかし、更に「”fuga”」を追記しても、 aux.capa には既に余裕があるため新たな文字列領域の確保は行われず文字列長の len のみが増えています。
より視覚的にわかりやすくするため、文字列の追記を繰り返してメモリ確保の様子をプロットして見てみます。
ここで、「expected」は追記の結果できた文字列長、「ObjectSpace」が実際に malloc したメモリの量に相当します。
追記を繰り返すたびに realloc の単位が大雑把になっていく様子が、この階段状の形状からもわかります。
そんなわけで、 Ruby で文字列の連結を繰り返す処理をする際には、「+=」で繋げていく (こちらは都度 malloc & free が発生する) よりも、「<<」 (concat) で追記していく方がずっとパフォーマンスが良くなります。
String::new(capacity:) であらかじめ malloc しておく
また、 String::new には capacity というパラメーターがあり、初期化時に capa の値を明示的に与えることができます。
最終的な文字列のサイズの上限が予め分かっている場合は、その値をセットしておくことで、初期化時に一度 malloc するだけで後は realloc しなくて済むようになるので、パフォーマンスを向上させることができます。
s = String.new(capacity: 1024) 100.times do s << 'hogefuga' end
実際に capa の値を見てみます。
% RBENV_VERSION=3.1.6 irb -I. -rinspect_rstring irb(main):001> s = String.new(capacity: 1024); irb(main):002> s.inspect_rstring; VALUE: 0x27b64807dbd8 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 0 rstring->as.heap.ptr: 0x27b645559a00 rstring->as.heap.aux.capa: 1024 irb(main):003>
文字列長 len は 0 ですが、 capa は指定した値の 1024 となっているのがわかります。
ところで、余談ですが、この記事を書いている途中で Ruby 3.3 で capacity の扱いに問題が出ていた (確保されるサイズが指定の値と異なってしまう) のに気付きました。
早速報告してみたところ、サクッと修正していただけましたので、 Ruby 3.3.4 では直っています。
aux.shared で文字列データを共有する
あと、共用体のもう一方の aux.shared というのも気になります。
以下のような例 (RHG から引用) を考えます。
while true do # 永遠に繰り返す a = "str" # 内容が「str」である文字列を生成しaに代入 a.concat("ing") # aの指すオブジェクトに直接"ing"を追加する p(a) # "string"と表示される end
この場合、リテラルの “str” は毎回同じ文字列値を生成することになりますが、文字列を破壊的に変更することになるまでは、いちいち生成せずに使い回した方が効率よくなりそうです。
というわけで、そのような場合には、いちいち文字列を malloc せず、 aux.shared に参照先の RString のポインタだけをセットしておき、メモリ確保をサボることがあります。
RString (shared) RString(a) RString(literal) +---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+ | RBasic | +----> RBasic' | + + | + + | | | | | +---+---+---+---+---+---+---+---+ | +---+---+---+---+---+---+---+---+ | len (== len') | | | len' | +---+---+---+---+---+---+---+---+ | +---+---+---+---+---+---+---+---+ +-- ptr (== ptr') | | +-- ptr' | | +---+---+---+---+---+---+---+---+ | | +---+---+---+---+---+---+---+---+ | | aux.shared --+ | | aux.capa | | +---+---+---+---+---+---+---+---+ | +---+---+---+---+---+---+---+---+ | | | | +--------------------+-----+ +--------------------------------------+------> C string | | +--------------------+-----+
このようにループ内でリテラル文字列を共有するパターンの他に、 String#dup による文字列オブジェクト複製の直後にも文字列データを共有したりします。
こちらも見てみます。
% RBENV_VERSION=3.1.6 irb -I. -rinspect_rstring irb(main):001> s = "*" * 1024; irb(main):002> s.inspect_rstring; VALUE: 0x898076863a0 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 1024 rstring->as.heap.ptr: 0x89802a5ab00 rstring->as.heap.aux.capa: 1024 irb(main):003> t = s.dup; irb(main):004> t.inspect_rstring; VALUE: 0x8980a5ced38 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 1 rstring->as.heap.len: 1024 rstring->as.heap.ptr: 0x89802a5ab00 rstring->as.heap.aux.shared: 0x8980a5ced10 irb(main):005> s.inspect_rstring; VALUE: 0x898076863a0 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 1 rstring->as.heap.len: 1024 rstring->as.heap.ptr: 0x89802a5ab00 rstring->as.heap.aux.shared: 0x8980a5ced10 irb(main):006>
s を t に dup すると、 t には STR_SHARED_P という shared のフラグが立ち、 ptr のポインターがオリジナルの文字列データを指していることが分かります。
ところが、 aux.shared は s でない新たな別のオブジェクトを指しているようです。それどころか s も shared になっていて、やはりこのオブジェクトを指しています。
このオブジェクトが何なのか更に見てみます。ここで draw_object_by_value というメソッドは VALUE の値からオブジェクトを引きずり出してくるのですが、かなり危険なことをしていて、下手をすると Ruby インタープリターが落ちるので要注意です。
irb(main):006> u = draw_object_by_value 0x8980a5ced10 => "***********************************************************************... irb(main):007> u.inspect_rstring; VALUE: 0x8980a5ced10 OBJ_FROZEN: 1 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 1024 rstring->as.heap.ptr: 0x89802a5ab00 rstring->as.heap.aux.capa: 1024 irb(main):008>
やはり s とも t とも違う別の文字列オブジェクトができていたようです。
STR_SHARED_P フラグが無いので、文字列データの実体がこの新たな文字列オブジェクトのものになったということのようです。また、 OBJ_FROZEN フラグが立ってるので freeze されているようです。
RString(s) RString(?) +---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+ | RBasic | +----> RBasic' | + + | + + | | | | | +---+---+---+---+---+---+---+---+ | +---+---+---+---+---+---+---+---+ | as.heap.len (=as.heap.len') | | | as.heap.len' | +---+---+---+---+---+---+---+---+ | +---+---+---+---+---+---+---+---+ +-- as.heap.ptr (=as.heap.ptr') | | +-- as.heap.ptr' | | +---+---+---+---+---+---+---+---+ | | +---+---+---+---+---+---+---+---+ | | as.heap.aux.shared --+ | | as.heap.aux.capa | | +---+---+---+---+---+---+---+---+ | | +---+---+---+---+---+---+---+---+ | | | | | | +--------------------+-----+ +-----------------------------------|--+------> C string | | | | +--------------------+-----+ RString(t) | | +---+---+---+---+---+---+---+---+ | | | RBasic | | | + + | | | | | | +---+---+---+---+---+---+---+---+ | | | as.heap.len (=as.heap.len') | | | +---+---+---+---+---+---+---+---+ | | +-- as.heap.ptr (=as.heap.ptr') | | | | +---+---+---+---+---+---+---+---+ | | | | as.heap.aux.shared --+ | | +---+---+---+---+---+---+---+---+ | | | | | +--------------------------------------+
また、最近のバージョンだと、文字列データ末尾を共有するパターンもあります。
どういうことかと言うと、例えば、
% RBENV_VERSION=3.1.6 irb -I. -rinspect_rstring irb(main):001> s = "hogefugapiyo" * 100 => "hogefugapiyohogefugapiyohogefugapiyohogefugapiyohogefugapiyohogefugapiy... irb(main):002> t = s[4..] => "fugapiyohogefugapiyohogefugapiyohogefugapiyohogefugapiyohogefugapiyohog... irb(main):003> u = t[4..] => "piyohogefugapiyohogefugapiyohogefugapiyohogefugapiyohogefugapiyohogefug... irb(main):004>
のように、元の文字列の先頭を削って後ろの部分は変わらない文字列を生成する場合、
irb(main):004> s.inspect_rstring; VALUE: 0x16145b84168 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 1 rstring->as.heap.len: 1200 rstring->as.heap.ptr: 0x1614065ab00 rstring->as.heap.aux.shared: 0x161459fc390 irb(main):005> t.inspect_rstring; VALUE: 0x161459fc368 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 1 rstring->as.heap.len: 1196 rstring->as.heap.ptr: 0x1614065ab04 rstring->as.heap.aux.shared: 0x161459fc390 irb(main):006> u.inspect_rstring; VALUE: 0x16145915bc0 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 1 rstring->as.heap.len: 1192 rstring->as.heap.ptr: 0x1614065ab08 rstring->as.heap.aux.shared: 0x161459fc390 irb(main):007>
dup と同じように s も t も u も shared になっていますが、 ptr の指しているアドレスの値を良く見てみると、 t が 4 バイト、 u は更に 4 バイト、後ろにずれていることがわかります。
+---+---+---+---+---+---+---+---+---+---+---+---+-------+---+ | h | o | g | e | f | u | g | a | p | i | y | o | ... |NUL| +-^-+---+---+---+-^-+---+---+---+-^-+---+---+---+-------+---+ +-----+ | | | s | ptr -----+ | | +-----+ | | +-----+ | | t | ptr ---------------------+ | +-----+ | +-----+ | u | ptr -------------------------------------+ +-----+
C の文字列としては文字列ポインタをずらすだけで部分文字列が得られるので、敢えて別途 malloc しなくても済むわけです。
例えば、固定長レコードの文字列データの読み出しや TLV (Type-Length-Value) 形式のバイナリデータのパースなど、先頭から決まったバイト数ずつ読み進めていくような処理を行う際には、これで効率良くいけそうです。
Ruby 1.9 以降
Ruby 1.9 では RString はこんな風に拡張されました。
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; };
新たに as という共用体ができ、これまでのデータは as.heap に追いやられ、新たに as.ary というのができています。
embed で文字列データを RVALUE に埋め込む
Ruby 1.9 では RVALUE に「embed」という考え方が導入され、文字列や配列などデータのうち、サイズの小さいモノは別途 malloc するのではなく RVALUE の中に埋め込んでしまうことで、 malloc & free のオーバーヘッドを削減でき、またキャッシュの局所性を高めることができます。
RString (embed) +---+---+---+---+---+---+---+---+ | RBasic | + + | | +---+---+---+---+---+---+---+---+ | | | | | | | | | +---+---+---+---+---+---+---+---+ | | | | | | | | | +---+---+---+---+---+---+---+---+ | | | | | | | |NUL| +---+---+---+---+---+---+---+---+
このとき、文字列長などの情報は RBasic の flags にセットされており、文字列データとして使えるのは残り 40 – 16 = 24 バイト分ですが、文字列終端の NUL が必要なので、結局 23 バイトまでとなります。
そんなわけで、 23 バイトまでの短い文字列であれば効率良く扱うことができるようになりました。
ちなみに Array なら 3 つまで embed で扱います。何故 3 つまでなのかはこの図を見ると一目瞭然でしょうか。
では、この embed がどう扱われているかを見てみます。
% RBENV_VERSION=3.1.6 irb -I. -rinspect_rstring irb(main):001> s = "hoge"; irb(main):002> s.inspect_rstring; VALUE: 0x16cfc2b95430 OBJ_FROZEN: 0 STR_EMBED_P: 1 STR_SHARED_P: 0 rstring->as.embed.ary: 0x16cfc2b95440 irb(main):003> 0x16cfc2b95440 - 0x16cfc2b95430 => 16 irb(main):004>
embed のフラグ STR_EMBED_P が立って embed.ary に文字列がセットされていることがわかります。
試しに embed.ary から VALUE (RString のアドレス) を引いてみると 16 となっており、丁度 RBasic の 16 バイト分だけ後ろに位置していることが、この結果からもわかりました。
なお、embed な文字列に追記して埋め込める容量を超えた場合は capa に切り替わります。逆に capa な文字列を埋め込める容量まで切り詰めると embed に切り替わります。
irb(main):004> s << "*" * 30 => "hoge******************************" irb(main):005> s.inspect_rstring; VALUE: 0x16cfc2b95430 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->as.heap.len: 34 rstring->as.heap.ptr: 0x16cfc2ca6f70 rstring->as.heap.aux.capa: 47 irb(main):006> s.replace("fuga") => "fuga" irb(main):007> s.inspect_rstring; VALUE: 0x16cfc2b95430 OBJ_FROZEN: 0 STR_EMBED_P: 1 STR_SHARED_P: 0 rstring->as.embed.ary: 0x16cfc2b95440 irb(main):008>
Ruby 3.2 以降
Ruby 3.2 では RString はこんな風になりました。
※ これは 3.3 のもので 3.2 と若干違いますが、本質的なところは変わっていません。また 3.1 でも USE_RVARGC を有効にしてビルドすることで同等になります。
struct RString { struct RBasic basic; long len; union { struct { char *ptr; union { long capa; VALUE shared; } aux; } heap; struct { char ary[1]; } embed; } as; };
構造体の定義を見る限りでは、配置が入れ替わっただけで情報量はあまり変わってないので違いは良く分からないかもしれませんが、 ary のサイズが 1 と、いかにもダミーっぽい値になっているのが目に付きます。
可変幅アロケーション (Variable Width Allocation (VWA))
このバージョンから RVALUE に「可変幅アロケーション (Variable Width Allocation (VWA))」というものが導入されました。
VWA では、 RVALUE は 40 の 2 の累乗倍、すなわち 40, 80, 160, 360, 640 バイトの可変幅を取るようになりました。
Ruby 3.2 から GC::stat_heap というメソッドでヒープの中を見ることができるようになっています。
% RBENV_VERSION=3.2.4 irb irb(main):001> GC.stat_heap => {0=> {:slot_size=>40, :heap_allocatable_pages=>0, :heap_eden_pages=>27, :heap_eden_slots=>44208, :heap_tomb_pages=>0, :heap_tomb_slots=>0, :total_allocated_pages=>27, :total_freed_pages=>0, :force_major_gc_count=>0}, 1=> {:slot_size=>80, :heap_allocatable_pages=>21, :heap_eden_pages=>52, :heap_eden_slots=>42555, :heap_tomb_pages=>0, :heap_tomb_slots=>0, :total_allocated_pages=>52, :total_freed_pages=>0, :force_major_gc_count=>0}, 2=> {:slot_size=>160, :heap_allocatable_pages=>11, :heap_eden_pages=>20, :heap_eden_slots=>8176, :heap_tomb_pages=>0, :heap_tomb_slots=>0, :total_allocated_pages=>20, :total_freed_pages=>0, :force_major_gc_count=>0}, 3=> {:slot_size=>320, :heap_allocatable_pages=>44, :heap_eden_pages=>6, :heap_eden_slots=>1222, :heap_tomb_pages=>0, :heap_tomb_slots=>0, :total_allocated_pages=>6, :total_freed_pages=>0, :force_major_gc_count=>0}, 4=> {:slot_size=>640, :heap_allocatable_pages=>96, :heap_eden_pages=>3, :heap_eden_slots=>306, :heap_tomb_pages=>0, :heap_tomb_slots=>0, :total_allocated_pages=>3, :total_freed_pages=>0, :force_major_gc_count=>0}} irb(main):002>
このように、ヒープの中に RVALUE のサイズ毎に「プール」という領域が設けられ、更にそのプール毎に RVALUE のスロットを管理する、というような階層になりました。
そして、 RString 構造体の方も 615 (= 640 から RBasic, len, NUL の分を引いた値) バイトまで embed として扱えるようになりました。
Ruby 3.3 でこれを見てみます。
% RBENV_VERSION=3.3.3 irb -I. -rinspect_rstring irb(main):001> s = "hoge" => "hoge" irb(main):002> s.inspect_rstring; VALUE: 0x36cc371f150 OBJ_FROZEN: 0 STR_EMBED_P: 1 STR_SHARED_P: 0 rstring->len: 4 rstring->as.embed.ary: 0x36cc371f168 irb(main):003> s = "*" * 600; irb(main):004> s.inspect_rstring; VALUE: 0x36cc6cff018 OBJ_FROZEN: 0 STR_EMBED_P: 1 STR_SHARED_P: 0 rstring->len: 600 rstring->as.embed.ary: 0x36cc6cff030 irb(main):005> s = "*" * 650; irb(main):006> s.inspect_rstring; VALUE: 0x36cc69bc310 OBJ_FROZEN: 0 STR_EMBED_P: 0 STR_SHARED_P: 0 rstring->len: 650 rstring->as.heap.ptr: 0x36cc0b13500 rstring->as.heap.aux.capa: 650 irb(main):007>
確かに 600 バイトを超えても embed で扱われていることがわかります。 615 バイトより大きな文字列になると capa になります。
RString (VWA) embed (~3.1.0) embed (3.2.0~) +---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+ | RBasic | | RBasic | + + + + | | | | +---+---+---+---+---+---+---+---+ +---+---+---+---+---+---+---+---+ | as.embed.ary | | len | + + +---+---+---+---+---+---+---+---+ | | | as.embed.ary | + + + + | | | | +---+---+---+---+---+---+---+---+ : : : : : : | | +---+---+---+---+---+---+---+---+
ところで、こうして見比べてみると、 Ruby 3.1 までの embed には len が無いのが気になります。
実は 3.1 までの embed の文字列長は RBasic の flags の中の 5 ビットで保持していました。 5 ビットあれば 0 ~ 31 まで扱えるので embed の最大文字列長の 23 バイトも余裕でした。
ですが、 VWA では embed の最大文字列長は 615 バイトになっていますからそれでは足りません。 3.2 で embed でも len が必要となったのにはそういう理由もありそうです。
この VWA により、 Ruby 3.2 以降はかなり大きな文字列でも効率良く処理できるようになっているようです。実際、 VWA のハックを行った Shopify の中の方のベンチマークなどによるとパフォーマンスの向上が見られているようです。
むすび
普段 Ruby を使っていて中身がどうなっているかまで気にすることはほとんど無いと思いますし、特に意識する必要も無いかもしれませんが、いざパフォーマンスチューニングが必要になったときには役に立つのではないかと思いますので、内部の実装について知っておくと良いかもしれません。
また、 Ruby の実装の方もどんどん改善されているようですので、もしかすると一年後にはこの記事の内容も陳腐化して全く違う話になってしまっているかもしれません。引き続き動向をウォッチしたいと思っています。
参考文献
- Ruby ソースコード完全解説 – インプレスブックス
- Rubyソースコード完全解説
- 言わずと知れた Ruby Hacking Guide (RHG)
- 参考になる部分は多いですが Ruby 1 ベースなので古い
- Rubyのしくみ Ruby Under a Microscope | Ohmsha
- Ruby 2 ベースなので若干古い
- Ruby内部の文字列を共有してスピードアップする(翻訳)|TechRacho by BPS株式会社
- VWA を導入した Shopify の中の方による解説