RubyのRactorを解き放つ(3)汎用インスタンス変数テーブルの競合を解消する(翻訳)
本シリーズの過去2本の記事では、以下のことを解説しました。
- Ractorの実行可能性を阻んでいる大きな要因の1つは、Ractorが完全にパラレル実行されるはずであるにもかかわらず、多くの場合シングルスレッドよりもパフォーマンスが落ちているため。
- その理由は、RubyのVM(仮想マシン)やランタイムに多数のコードパスが存在し、それらが未だにグローバルVMロックによって保護されているため。
また、これらの競合ポイントのうち、object_id
メソッドの競合とクラスインスタンス変数の競合を私がどのように削除したかについても説明しました。
その後、私と元同僚が多くの競合ポイントを解消もしくは軽減したことで、状況は劇的に改善しました。ほとんどの問題は、クラスインスタンス変数の競合に関する記事で解説した
リード・コピー・アップデート(RCU)という技法に帰着するので、個別の問題解決を記事化するつもりはありません。
しかし私が書いておきたい競合ポイントが1つあります。それが汎用インスタンス変数テーブル(generic instance variables table)です。
🔗 インスタンス変数のしくみ
Rubyユーザーなら、「すべてはオブジェクトである」という概念について既にお馴染みでしょうし、それはある意味真なのですが、だからといってすべてのオブジェクトが平等にできているとは限りません。これについては過去記事でもある程度説明したので、ここでは手短に述べます。
インスタンス変数の文脈では、Ruby VMには(数え方にもよりますが)本質的に3種類または4種類のオブジェクトが存在します。
種別その1は「即値(immediates)」です。
- 値の小さい整数(
1
など) - ブーリアン(
true
、false
) - 静的なシンボル(
:foo
など)
ただし"bar".to_sym
のような動的なシンボルは含めない
これらが即値と呼ばれるのは、事実上メモリ上に存在していないからです。これらの値は、ヒープ上にアロケーションされたオブジェクトスロットを持ちません。
これらが参照するのは、それらの「値」そのものです。言い換えれば、即値とはいわゆるタグ付きポインタ(tagged pointers)に過ぎないのです。
すなわち、即値がインスタンス変数を持つことはありえません。Rubyは、即値が他のオブジェクトと同等であるという幻想を壊さないために、即値をfrozenとして扱います。
>> 42.instance_variable_set(:@test, 1)
(irb):2:in 'Kernel#instance_variable_set': can't modify frozen Integer: 42 (FrozenError)
種別その2は、ユーザー定義クラスで使われる、より一般性の高いT_OBJECT
です。
T_OBJECT
のインスタンス変数は、オブジェクトのスロット内部に配列のような形で保存されます。3つのインスタンス変数を持つ以下のオブジェクトを考えてみましょう。
class Foo
def initialize
@a = 1
@b = 2
@c = 3
end
end
これは、サイズが40B
の基本オブジェクトスロットに収まります。うち16B
はオブジェクトのフラグやそのクラスへのポインタに使われ、残りの24B
が3つのインスタンス変数の参照に使われます。
フラグ | klass | @a | @b | @c |
---|---|---|---|---|
T_OBJECT | 0xffeff | 1 | 2 | 3 |
インスタンス変数が後から追加されてスロットからあふれたら、Ruby VMは別のメモリ領域をアロケーションしてインスタンス変数をそこに「スピル(spill)」させる必要が生じることもあります。
しかしこれは、実際にはかなりまれにしか生じません。VMは、各クラスのインスタンスが所有している変数の個数をトラッキングしているので、Rubyがインスタンス変数を「スピル」させる必要が生じたとしても、そのクラスで今後生成されるインスタンスは、必ずより大きなスロットにアロケーションされます。
種別その3は、T_CLASS
とT_MODULE
です。これらは直前の記事で取り上げたトピックでもあるので、ここでは手短にとどめます。
クラスインスタンス変数のレイアウトもT_OBJECT
の場合と似ていますが、置き場所が「コンパニオン(companion)」スロットである点が異なります。
class Foo
@a = 1
@b = 2
@c = 3
end
このクラスのメモリ上のレイアウト自身も、その「コンパニオン」スロットへの参照を保存しています。
フラグ | klass | obj_fields | ... | ... |
---|---|---|---|---|
T_CLASS |
0xffeaa | 0xffdddd |
その残りのスロットはT_OBJECT
のときと完全に同じですが、その種別が「内部メモリ」用のT_IMEMO
である点が異なります。
フラグ | klass | @a | @b | @c |
---|---|---|---|---|
T_IMEMO /fields |
0xffeaa | 1 | 2 | 3 |
この種のオブジェクトは、Rubyのユーザーが直接アクセスすることはおろか参照すらできない、基本的に不可視なオブジェクトです。しかしVM内部では、malloc
やfree
による手動メモリ管理ではなく、GC(ガベージコレクタ)によって管理されているメモリにさまざまなデータを保存するために用いられています。
残りの種別は、 Hash
やArray
やString
などのオブジェクトです。
これらのオブジェクトスロット内のスペースは既に利用済みです。たとえば、String
用のスロットは、文字列のlength
やcapacity
を保存するのに使われており、文字列の長さが十分小さければ、文字列を構成するバイト列自身を保存するのにも使われます。文字列が長い場合は、手動アロケーションされたバッファへのポインタが保存されます。
ただしRubyでは、その気になれば文字列にインスタンス変数を定義することも可能なのです。
>> s = "test"
>> s.instance_variable_set(:@test, 1)
>> s.instance_variable_get(:@test)
=> 1
Ruby VM内部には、これを可能にするためのハッシュテーブルが存在します。これは以前genivar_tbl
、すなわち汎用インスタンス変数ハッシュテーブル(Generic Instance Variables Hash-Table)と呼ばれていましたが、私がobject_id
の改善を手掛けたときにgeneric_fields_tbl_
とリネームしました。
このしくみについては前回のobject_id
記事で詳しく説明しましたが、これは本当に核心となるトピックなので、ここでもう少し詳しく説明しておきます。
今回も、わかりやすくするためにRuby擬似コードを使います。
module GenericIvarObject
GENERIC_FIELDS_TBL = Hash.new.compare_by_identity
def instance_variable_get(ivar_name)
if ivar_shape = self.shape.find(ivar_name)
RubyVM.synchronize do
if buffer = GENERIC_FIELDS_TBL[self]
buffer[ivar_shape.index]
end
end
end
end
end
このグローバルハッシュGENERIC_FIELDS_TBL
のキーはオブジェクトへの参照で、値は、手動でアロケーションされたバッファへのポインタです。このバッファ内には、T_OBJECT
やT_IMEMO/fields
の内部にあるのと同様に、参照の配列が存在します。
これは、さまざまな理由から理想的とは言えません。
まず、ハッシュ探索を強いる手法は、T_OBJECT
でやっているようなオフセット読み出しや、T_CLASS
やT_MODULE
でやっているような参照をたどる手法に比べても相当コストが高くなります。
さらに困ったことに、Ractorを複数使うシナリオでは、操作全体にわたってVMロックを取得しなければならなくなります。
- 理由1: ハッシュテーブルはグローバルであり、スレッド安全ではない
- 理由2: 手動でアロケーションされたバッファを別のRactorが読み取り中に解放できないようにしなければならなくなる1。
ここまで読み進めていただければ何が問題点であるかを理解いただけたかと思います。つまり、Object
(実際にはBasicObject
ですが)やModule
の直接の先祖「でない」オブジェクト内にあるインスタンス変数を読み書きするコードは、Ractorの競合ポイントになる可能性がありえるということです。
🔗 めったにそうならないのでは?
この問題に対して私が何を変更できるかを詳しく検討する前に、そもそもこれが本当に重大な問題なのか疑問に思われるかもしれません。
そしてその疑問は実に筋が通っています。開発者の時間は無限ではありませんので、この競合ポイントを削除する価値があるかどうかは、コードパスがどれだけホット(=実行頻度が高い)か、そして競合ポイントの削除がどの程度困難なのかという点に帰着します。
私がこの問題を検討し始めた当初は、問題をT_STRUCT
の観点から見ていました。私は、Struct
オブジェクトやData
オブジェクトを競合ポイントにしたくなかったのです。たとえば、以下のようにStruct
が何らかのコードジェネレータで使われることは珍しくありません。
Address = Struct.new(:street, :city) do
def something_else
@something_else ||= compute_something
end
end
Struct.new
やData.define
で作成されるオブジェクトはT_OBJECT
ではなくT_STRUCT
です。つまりこの場合、スロット内のスペースは、このインスタンス変数@something_else
のためではなく、宣言されたフィールド(:street
と:city
)のために使われます。
私が想定していたもうひとつのパターンはC拡張です。RubyのC拡張をAPIに公開する必要が生じたときは、TypedData
APIが使われます。このAPIを用いることでT_DATA
オブジェクトを生成できます。
しかし、C言語側での処理をできるだけ減らして、そのCクラスをRubyコードで拡張するといった手法は、C拡張では珍しくありません。
その例としてtrilogy
gemを取り上げてみましょう。このgemにはCメソッドが大量に定義されています。
RUBY_FUNC_EXPORTED void Init_cext(void)
{
VALUE Trilogy = rb_const_get(rb_cObject, rb_intern("Trilogy"));
rb_define_alloc_func(Trilogy, allocate_trilogy);
rb_define_private_method(Trilogy, "_connect", rb_trilogy_connect, 3);
rb_define_method(Trilogy, "change_db", rb_trilogy_change_db, 1);
rb_define_alias(Trilogy, "select_db", "change_db");
rb_define_method(Trilogy, "query", rb_trilogy_query, 1);
//...
}
しかしそのCクラスをRubyコードで拡張すると以下のようになります。
class Trilogy
def initialize(options = {})
options[:port] = options[:port].to_i if options[:port]
mysql_encoding = options[:encoding] || "utf8mb4"
encoding = Trilogy::Encoding.find(mysql_encoding)
charset = Trilogy::Encoding.charset(mysql_encoding)
@connection_options = options
@connected_host = nil
_connect(encoding, charset, options)
end
end
これはCコードで書く量を減らしてRubyで書ける量を増やせるので、私が愛してやまないパターンです。このパターンが使えなかったら、Ractorでパフォーマンスを改善するためにC拡張の部分を複雑にしなければならなくなって嫌気が差したことでしょう
次に、いくつか古典的な例を見ていきましょう。
RailsのActiveSupport::SafeBuffer
はString
のサブクラスで、そこに@html_safe
というインスタンス変数が付け加えられます。
module ActiveSupport
class SafeBuffer < String
def initialize(str = "")
@html_safe = true
super
end
# (...略)
end
end
つまり、このようにコアの型を継承するようなコードは珍しくないので、ここが最終的にホットスポット(=実行頻度の高い場所)になりえます。私はパフォーマンス以外の理由から、このパターンをなるべく避けることをおすすめしていますが、実用上の理由からあえてこの書き方をするユーザーもいます。
🔗 あるデータポイントの話
ともあれ、このコードパスを改善するのは有用だという確信がかなり深まったので、作業に取りかかりました。ところがやがてデータの提供を求められたので(時系列については伏せますが)、ここで共有したいと思います。
私は最初に、環境変数をトリガーとして昔ながらのprintデバッグを行うというお気に入りの手法を試してみました。
if (getenv("DEB")) {
fprintf(stderr, "%s\n", rb_obj_info(obj));
}
次に、Shopityのyjit-bench
スイートに手を加えてベンチマークループの開始段階でENV["DEB"] = "1"
を設定するようにしてみました。今回私が関心を抱いているのは、起動時のコードパスではなく実行時のコードパスだからです。
続いて、これもShopiyのshipit
ベンチマークを実行しつつ、STDERRをファイルにリダイレクトしました。
$ bundle exec ruby benchmark.rb 2> /tmp/ivar-stats.txt
さらに、irb
で簡単なデータ処理を行いました。
File.readlines("/tmp/ivar-stats.txt", chomp: true).tally.sort_by(&:last).reverse
結果は以下のとおりです。これは素のRails 8アプリケーションで、何も特別なことはしていません。
[
["VM/thread", 4886969],
["T_HASH", 229501],
["SQLite3::Backup", 122531],
["T_STRING", 70597],
["xmlDoc", 23625],
["T_ARRAY", 9039],
["OpenSSL/Cipher", 2800],
["xmlNode", 2025],
["encoding", 358],
["time", 199],
["proc", 68],
["T_STRUCT", 38],
["OpenSSL/X509/STORE", 3],
["Psych/parser", 2],
["set", 1],
]
予想通りT_STRUCT
も見つかりましたが、他のものに比べればまったく大した数ではありませんでした。あまり馴染みのないものについて軽く説明を加えておきます。
"VM/Thread"
は文字通りThread
のインスタンスです。xmlNode
とxmlDoc
はnokogiri
gemのオブジェクトです。T_
で始まっていないその他すべてのものはT_DATA
です。
そして、このリストにT_HASH
が浮かび上がってくるとは、私にはまったく思いもよりませんでした。これがどこからやってきたのか、まるで見当が付かなかったので、別のハックを試してみました。
if (getenv("DEB") && TYPE_P(obj, T_HASH) && (rand() % 1000) == 0) {
rb_bug("here");
}
このrb_bug
関数はRuby VMをabort(中止)させてクラッシュレポートを出力します。このクラッシュレポートにはRubyレベルのバックトレースが含まれています。
そのおかげで、T_HASH
がRack::Utils::HeaderHash
のインスタンスだったことが判明しました。
T_ARRAY
については、ほとんどがRailsのActiveSupport::Inflector::Inflections::Uncountables
由来だったようです。
"VM/Thread"
はRailsのActiveSupport::IsolatedExecutionState
由来でした。
それ以外はことごとく、私が共有したtrilogy
の例と同様の、C拡張で定義されたさまざまなT_DATA
でした。
yjit-bench
リポジトリにある他のベンチマークもいくつか試してみたところ、同様の汎用インスタンス変数が多数見つかりました。
つまり、先ほどの質問に答えると、ホットスポットとしては大きくはないものの、特にT_DATA
については、Ractor以外の理由もあって最適化する価値が十分あるぐらい使われまくっていると確信しています。
🔗 構造体をシェイプ化する
しかし前述したように、データがすべて揃うまでの私はT_STRUCT
に目を奪われていました。構造体オブジェクトのレイアウトはT_OBJECT
のレイアウトと実によく似ていますが、レイアウトのスペースをインスタンス変数用に使うのではなく、「メンバー(member)」用に使っている点が異なります。
たとえば以下の構造体は、
struct = Struct.new(:field_1, :field_2).new(1, 2)
以下のようなレイアウトになります。
フラグ | klass | field_1 | field_2 | - |
---|---|---|---|---|
T_STRUCT |
0xbbeaa | 1 | 2 |
そういうわけで、私が当初思い描いていたアイデアは、構造体のメモリレイアウトを、シェイプ(shape)のインスタンス変数でやっているのと同じようにエンコードしたとすると、メンバーと変数が一緒に配置されて、以下のようなことが可能になるのでは、というものでした。
MyStruct = Struct.new(:field_1, :field_2) do
def initialize(...)
super
@c = 1
end
上のコードのレイアウトは以下のようになるでしょう。
フラグ | klass | field_1 | field_2 | @c |
---|---|---|---|---|
T_STRUCT |
0xffeaa | 1 | 2 | 3 |
見た感じ完璧そうです。すべてがオブジェクトスロットに埋め込まれるので、メモリ使用量もアクセス時間も最小限に抑えられそうなものです。
残念ながら、もう少し考えてみた結果、ここには重大な問題が1つ潜んでいることに気づきました。すなわち複雑なシェイプ(shape)です。
複雑なシェイプとは何かについては過去記事にも長々と書きましたが、手短に言うと、シェイプはRuby VM内でガベージコレクションされないのです。
つまり、さまざまなシェイプを大量に生産するコードがあると、Rubyはオブジェクトの最適化を解除して、オブジェクトのインスタンス変数をハッシュテーブルに保存するようになります。プログラムが可能なあらゆるシェイプスロットを利用する場合も同様です。
したがって、Struct
のメンバーがシェイプにエンコードされる事態が生じると、複雑な構造体を扱うために大量のフォールバック用コードパスが必要になりますが、Struct
オブジェクトは配列的に扱われる可能性があるため、一言でいうと不可能です。
>> Struct.new(:a, :b).new(1, 2)[1]
=> 2
そうなると、メンバーのオフセット以外何も取得できなくなります。つまり、構造体の最適化が解除されてハッシュに保存されると、逆インデックスがない限りメンバーにインデックスアクセスすることが不可能になります。かといって逆インデックスを導入すると極端に複雑になってしまいます。そういうわけで、このアイデアは放棄しました。
🔗 シェイプのオフセット
その数日後、Étienne Barriéとブレインストーミングしていて、もっとシンプルな解決方法を思いつきました。構造体のメンバーをシェイプにエンコードする代わりに、インスタンス変数開始オフセットをエンコードする、新しい種類のシェイプを導入するという方法です。
よく指摘されているように、シェイプはツリー構造になっているので、@a -> @b -> @c -> @d
という変数を持つオブジェクトの場合、そのシェイプツリーは以下のような感じになります。
ROOT_SHAPE
\- Ivar(name: :@a, index: 0, capacity: 3)
\- Ivar(name: :@b, index: 1, capacity: 3)
\- Ivar(name: :@c, index: 2, capacity: 3)
\- Ivar(name: :@d, index: 3, capacity: 8)
オフセットシェイプの場合は、インスタンス変数のリスト部分は同じですが、2つのメンバーを持つ構造体の場合は以下のようになります。
ROOT_SHAPE
\- Offset(index: index: 1, capacity: 3)
\- Ivar(name: :@a, index: 2, capacity: 3)
\- Ivar(name: :@b, index: 3, capacity: 8)
\- Ivar(name: :@c, index: 4, capacity: 8)
\- Ivar(name: :@d, index: 5, capacity: 8)
ここでもRuby VMがシェイプを使い果たした場合への対処が必要になりますが、少なくとも、最適化解除されてハッシュテーブルに移されるのはインスタンス変数だけにとどまり、構造体のメンバーはこれまで通りメモリ上で配列のように配置されるので、複雑さが大幅に軽減されます。
自分は今もこのアイデアはよいと思っているのですが、プロジェクトとしてはかなり大きくなりますし、不確実な部分もそれなりにあります。そこでPeter Zhuに私のソリューションを提案してみたところ、彼はさらにシンプルな案を出してくれました。
🔗 直接参照
汎用のインスタンス変数は、それらがオブジェクトスロットに埋め込まれていないことよりも、コンパニオンスロットを探索するためにグローバルハッシュテーブルを調べなければならない点の方が厄介です。
もちろん、汎用のインスタンス変数が埋め込まれていれば、データの局所性もパフォーマンスも向上しますが、ハッシュ検索と比較するとそれほど大きな違いはなく、追跡すべきポインタが単一で済むだけでも大きなメリットになります。
すなわち、Peterの提案は、構造体スロットの空きスペースをインスタンス変数を保持するバッファへの直接参照を保存場所として活用するというものでした。構造体は基本的に固定サイズの配列であるため、その参照を末尾の構造体メンバーの直後に保存できます。
疑似Rubyコードで表すとおおよそ次のようになります。
class Struct
def instance_variable_get(ivar)
if __slot_capacity__ > size
self[size].instance_variable_get(ivar)
else
# 汎用インスタンス変数テーブルを利用する
end
end
end
この戦略は、クラスやモジュールの戦略と本質的に同じです。
ちょうど数週間前に、汎用インスタンス変数がクラスと同じマネージドオブジェクトを使うようにリファクタリングしていた(T_IMEMO/fields
)おかげで、少なくとも理論上は非常に簡単でした。
そこで、Étienne Barriéと再びこのアイデアの実装に挑戦してみたのですが(#14095)、カプセル化が不十分だったためにプルリクが予想よりもはるかに巨大になってしまいました。
インスタンス変数を扱おうとすると、VMのあちこちで同じような巨大switch/case
ステートメントが出現して、オブジェクトレイアウトの3〜4つの種別ごとに分岐しようとします。つまり、T_STRUCT
を変更しようとするたびに、これらすべてでコードパスが1つずつ増えてしまい、残念な思いをします。
そこで、あえて一歩下がって汎用インスタンス変数のテーブルそのものをリファクタリングすることにしました(#14107)。これによって、テーブルのあらゆる読み書きで関数アクセスがほぼ2つだけで済むようになり、構造体オブジェクトの振る舞いを特殊化するのにふさわしい場所となりました。
余談ですが、私がRuby VMを手掛けるたびに、素晴らしいアイデアや精緻なアルゴリズムを得ることよりも、既存のものを1つも壊さずにコードをリファクタリングするうえで不可欠な努力を絶やさないことの方がよほど難しいと痛感します。何しろC言語には抽象化やカプセル化の機能がほとんどないので、あらゆる場所に癒着が見られます。
話を戻すと、このリファクタリングを終えたおかげで、Étienneと共同で行った同じプルリクを再実装できるようになり、しかもサイズが半分で済んだのです(#14129)。
こうして、汎用インスタンス変数を探索する関数は以下のような感じになりました。
VALUE
rb_obj_fields(VALUE obj, ID field_name)
{
RUBY_ASSERT(!RB_TYPE_P(obj, T_IMEMO));
ivar_ractor_check(obj, field_name);
VALUE fields_obj = 0;
if (rb_shape_obj_has_fields(obj)) {
switch (BUILTIN_TYPE(obj)) {
case T_STRUCT:
if (LIKELY(!FL_TEST_RAW(obj, RSTRUCT_GEN_FIELDS))) {
fields_obj = RSTRUCT_FIELDS_OBJ(obj);
break;
}
// (フォールスルー)
default:
RB_VM_LOCKING() {
if (!st_lookup(generic_fields_tbl_, (st_data_t)obj, (st_data_t *)&fields_obj)) {
rb_bug("Object is missing entry in generic_fields_tbl");
}
}
}
}
return fields_obj;
}
T_STRUCT
を扱うときにスロットに未使用スペースが残っていれば、 generic_fields_tbl
とRB_VM_LOCKING
を完全にバイパスします。
さらに、フォールバックパスに落ちすぎないようにするために、Structアロケータに手を加えて、インスタンス変数を持つ構造体に十分なスロットをアロケーションするようにしました。
static VALUE
struct_alloc(VALUE klass)
{
long n = num_members(klass);
size_t embedded_size = offsetof(struct RStruct, as.ary) + (sizeof(VALUE) * n);
if (RCLASS_MAX_IV_COUNT(klass) > 0) {
embedded_size += sizeof(VALUE);
}
// (省略...)
}
その結果、Ractorが関与していない場合でも構造体のインスタンス変数へのアクセスが著しく高速化しました。
compare-ruby: ruby 3.5.0dev (2025-08-06T12:50:36Z struct-ivar-fields-2 9a30d141a1) +PRISM [arm64-darwin24]
built-ruby: ruby 3.5.0dev (2025-08-06T12:57:59Z struct-ivar-fields-2 2ff3ec237f) +PRISM [arm64-darwin24]
warming up.....
| |compare-ruby|built-ruby|
|:---------------------|-----------:|---------:|
|member_reader | 590.317k| 579.246k|
| | 1.02x| -|
|member_writer | 543.963k| 527.104k|
| | 1.03x| -|
|member_reader_method | 213.540k| 213.004k|
| | 1.00x| -|
|member_writer_method | 192.657k| 191.491k|
| | 1.01x| -|
|ivar_reader | 403.993k| 569.915k|
| | -| 1.41x|
十分満足できる変更結果です。
🔗 他の型にも一般化する
有効なパターンができたので、次の問題はどこにこのパターンを適用するかです。
私はActiveSupport::SafeBuffer
には詳しいので、T_STRING
のインスタンス変数がかなり一般的に使われていることを確実に把握していました。そこで、T_STRING
のインスタンス変数にも同じトリックを使うことを思いつきました。
しかし残念ながら、T_STRUCT
でこの方法が可能だったのは、それらの配列サイズが本質的に固定だったためです。つまり、T_STRUCT
ではスロットの空き領域が今後他のことで必要になる可能性は決して生じないということです。
しかしT_STRING
やT_ARRAY
などの他の型では配列サイズが可変長になっています。スロットの末尾に空いているスペースに参照を保存するのであれば、ユーザーが文字列や配列に追記(append)したときに誤って参照を上書きしないよう、極めて注意深く処理しなければならなくなります。これは極めて難易度が高く、そのために複雑さを増す価値はおそらくないでしょう。
しかし私がRubyとRailsを両方手がけていて嬉しい点の1つは、Ruby側とRails側の双方から最適化を行えることです。Railsであるパターンのパフォーマンスがはかばかしくない場合は、Ruby側で最適化を試みることもできますし、Railsの振る舞いを変更するだけでよいこともあります。
ActiveSupport::SafeBuffer
の場合、そこに保存されるのは@html_safe = true
というブーリアンだけです。このバッファに何かがappendされると、最終的にこのブーリアンは反転してfalse
(つまり安全でない)に変わります。しかし、SafeBufferへのappendはめったに生じません。
String#html_safe
はほとんどの場合、後で別のバッファに追加するときにエスケープが不要であることを示すタグを文字列に追加することにしか使われません。つまりこのフラグは、ほぼすべてのインスタンスで反転されることはありません。
以上の知識を踏まえて、その変数の初期値を@html_safe = true
ではなく@html_unsafe = false
にしました(#55352)。存在しないインスタンス変数への参照はnil
に評価され、nil
はfalseでもあるため、変数に何も設定せずに済ませることが可能になります。したがって、@html_unsafe = false
を初期値にできるのです。
その結果、String#html_safe
は、Ractorが起動しない場合でも2倍速くなりました。
ruby 3.5.0dev (2025-07-17T14:01:57Z master a46309d19a) +YJIT +PRISM [arm64-darwin24]
Calculating -------------------------------------
String#html_safe (old) 6.421M (± 1.6%) i/s (155.75 ns/i) - 32.241M in 5.022802s
String#html_safe 12.470M (± 0.8%) i/s (80.19 ns/i) - 63.140M in 5.063698s
これは、いわゆるmechanical sympathy(機械への共感)2のよい例の1つだと思います。自分が使っているツールを詳しく知れば知るほど、ツールを効果的に使えるのです。
私はActiveSupport::Inflector::Inflections::Uncountables
についても学んだので、おそらくこれも同様に変更すべきなのでしょう。
しかし、注目に値する型がもうひとつあります。それがT_DATA
です。
🔗 TypedDataとは
ほんの数か月前まで、T_DATA
のスロットは全部使われていて、どこにも空きがありませんでした。
以下はRuby 3.4内部にあるRTypedData
というCの構造体ですが、フィールドごとにサイズのアノテーションを少々追加しました。
struct RTypedData {
/** この部分はすべてのRubyオブジェクトで共通 */
struct RBasic basic; // 16B
/**
* このrb_data_type_tフィールドには、Rubyがデータを処理する方法に関する
* さまざまな情報が保存される。これはRubyレベルのクラスに似ている面がある。
* (メソッド定義などを除く)
*/
const rb_data_type_t *const type; // 8B
/**
* これは常に1でなければならない
*
* @internal
*/
const VALUE typed_flag; // 8B
/** ラップしたい実際のCレベル構造体へのポインタ */
void *data; // 8B
};
手短に説明すると、最初の16B
は全Rubyオブジェクトで共有される共通のヘッダーに使われていました。8B
は、このオブジェクトをどう処理するか(例: ガベージコレクションの種別を指定する)をRubyに伝えるための別の構造体へのポインタを保存するのに使われていました。
それに続いて8B
が2つありますが、8B
の1つ目C拡張がアロケーションする可能性のある任意のメモリへのポインタ用の値です。
そして2つ目の8B
がtyped_flag
です。typed_flag
の「これは常に1でなければならない」というコメントを見ただけでは、これが何のためにあるのかよくわからないでしょう。
typed_flag
が存在する理由は、2009年にKoichi SasadaによってC拡張に導入された新しいAPIがRTypedData
であるからです。それ以前の歴史では、ネイティブメモリの一部をRubyオブジェクトでラップしなければならない場合はRData
APIが使われていました。このAPIには以下を提供する必要があります。
- メモリ領域へのポインタ
- GCするかどうかを指定するためのマーキング関数
- GCでメモリ解放に使われるfree関数
このAPIは非推奨ですが今日も生き残っていて、そのための構造体が存在することを確認できます。
/**
* @deprecated
*
* 古い「型なし(untyped)」のユーザーデータ。
* 利用方法は::RTypedDataとほぼ同じだが、コンパクションGCなどの重要な機能がサポートされていない。
* この構造体の利用は今後推奨されなくなった。どうしても必要な場合は、Rubyコア開発チームに利用方法を知らせて欲しい。
*
* @internal
*
* @shyouheiがこの型にRBIMPL_ATTR_DEPRECATEDを追加しようとしたが、
* Rubyコアで表示される警告が多すぎた。
* いつか再挑戦するかもしれない...とりあえず非推奨化のドキュメントを追加しておいた。
*/
struct RData {
/** 基本部分、フラグやクラスもここに含まれる */
struct RBasic basic;
/**
* この関数はオブジェクトにGCマークが付いている場合に呼び出される。
* 他のRubyオブジェクトへの参照が含まれている場合は、それらにもマーキングが必要。
* さもないとGCでデータが破壊される。
*
* @see rb_gc_mark()
* @warning これはGC実行中に呼び出される。その間はオブジェクトのアロケーションは不可能(そのためのGC実行なので)。
*
*/
RUBY_DATA_FUNC dmark;
/**
* この関数はオブジェクトが利用されなくなったときに呼び出される。
* メモリリークを回避するのに必要な処理をすべて行っておかなければならない。
*
* @warning これはGC実行中に呼び出される。その間はオブジェクトのアロケーションは不可能(そのためのGC実行なので)。
*/
RUBY_DATA_FUNC dfree;
/** ラップしたい実際のCレベル構造体へのポインタ */
void *data;
};
つまり、T_DATA
オブジェクトをRuby VM内のさまざまな場所で操作する必要がある場合は、その前に必ずT_DATA
オブジェクトがRTypedData
かRData
のどちらかなのかを知っておかなければなりません。
そこで登場するのがtyped_flag
です。これはRTypedData
構造体内では、RData
構造体内にあるdfree
ポインタと同じオフセットに配置されます。そしてさまざまな理由から、正当なC関数のポインタが厳密に1
に等しくなることは不可能です。
typed_flag
が1
に固定されている理由がこれです。このおかげで、rdata->dfree == 1
をチェックすればT_DATA
が型付きかどうかをチェックできるようになります。
私がこんなことを話題にしているのを不思議に思うかもしれません。その理由は、typed_flag
フィールドはたった1ビットの情報を保存するために8B
ものメモリを消費していて、これが私にとって長年謎のままだったからです。
実を言うと上の「これは常に1でなければならない」というコメントは古くなっています。私は昨年Peter Zhuと協力して埋め込みTypedData
オブジェクトを実装した(Peter Zhuの記事を参照)ため、場合によっては3
になることもあるのです。
しかしそれでも、typed_flag
フィールドは必要なビット数の32倍ものメモリを消費しています。誰かがこの2つのビットを保存するのにふさわしい場所を他に思いついてくれれば、この8B
のメモリが丸々空いて、T_IMEMO/fields
用の直接参照をそこに保存できるようになるでしょう。
🔗 ここでSet
が登場
しかし今年始めに、それをやってくれた人がいたのです。
RubyKaigi開発者ミーティングの直前になって、Jeremy EvansがSet
をコアクラスに組み入れてC言語で再実装してはどうかと持ちかけ(#21216)、そして提案は受理されました。その後、会期中にJeremyが私にRTypedData APIの使い方が適切かどうかレビューして欲しいと依頼してきました(#13074)。私はJeremyに、Set
オブジェクトをもっとスリムにして、埋め込みRTypedData
オブジェクトを活用する形でポインタの追跡を削減するためのいくつかの改善を提案しました。
しかしここで少々厄介なトレードオフが浮かび上がってきたのです。RTypedData
構造体のサイズは40B
ですが、埋め込みを使うときはdata
ポインタを再利用するので32B
で済みます。一方、Jeremeyが保存する必要のあるset_table
のサイズは56B
もあるので合計88B
となり、これは特に厄介な数値です。
これが厄介な理由は、標準の80B
GC用スロットに収めるには8B
でも大きすぎるからです(88という数字を騒ぎ立てる連中がいるからではありません3)。そのため、これを埋め込みとしてマーキングすると、メモリフットプリントが40 + 56 = 96B
〜160B
程度まで増加し、無駄なメモリ領域を増やしてしまいます。
率直に言えば、アプリケーションがSet
を大量に使うのでもない限り大きな問題にはなりませんでしたが、Jeremyは相当悩んでいたようです。
その数週間後にJeremyが思いついたのは、メモリ上の2つのビットをRTypedData.type
とRData.dmark
の下位ビットに移動することで埋め込みTypedDataオブジェクトの8B
を解放し、それからSet
オブジェクトを80B
にうまく収まるようにするという方法でした((https://github.com/ruby/ruby/pull/13190))
ここでも、メモリアラインメントのルールが存在するのでポインタの下位3ビットは設定できないという前提で、独自の情報をそこに保存できるとしていました。
しかし今の私は、この領域を、コンパニオンのT_IMEMO/fields
への参照を保存するのに使う方が有効かもしれないと思うようになったので(#14134)、グローバルなインスタンス変数テーブルをスキップできそうだと思っています。問題は、ここにもトレードオフが顔を出していることです。CPUサイクルを節約するためにメモリを余分に消費することは容認できますが、どちらがよいかは実際には判断の問題です。
Jeremyがこの問題で悩んだのと同様に、私もこの問題で悩むようになりました。そしてSet
オブジェクトでメモリをあと8B
節約する方法があるかどうかを探し始めました。
🔗 Set
のメモリサイズを削減する
こうして、Set
のどこかに削ってもよい不要な要素がないかどうかを、眉間にシワを寄せながら探し始めました。
struct set_table {
/* テーブルのキャッシュ済み機能(詳しくはst.cを参照) */
unsigned char entry_power, bin_power, size_ind;
/* テーブルが再構築された回数 */
unsigned int rebuilds_num;
const struct st_hash_type *type;
/* テーブル内の現在のエントリ数 */
st_index_t num_entries;
/* キーアクセスで使われるビン配列(array of bin) */
st_index_t *bins;
/* 配列エントリ内の開始の添字と境界の添字。
entries_startsとentries_boundは
[0,allocated_entries]区間内にある。 */
st_index_t entries_start, entries_bound;
/* サイズが2^entry_powerの配列 */
set_table_entry *entries;
};
最初に目をつけたのはnum_entries
、entries_start
、entries_bound
というトリオです。3つともサイズが8B
の整数値なので、どれかひとつでも削れれば文字通りSet
は完了です4。
Set
の実装はあまりよくわかっていなかったのですが、エントリ数が既にわかっていれば、開始と終了のオフセット値は両方なくてもいいだろうと推測しました。理論上は、entries_bound
への参照をentries_start + num_entries
で丸ごと置き換えるだけでいけそうです。
様子のわからないコードを相手に実験を試みるときは、仮説を証明する形で取り組むようにしています。ここでは、そのための小さなヘルパー関数を作ってみました。
static inline st_index_t
set_entries_bound(const struct set_table *set)
{
RUBY_ASSERT(set->entries_start + set->num_entries == set->entries_bound);
return set->entries_bound;
}
そして直接アクセスをこのヘルパーによるset->entries_bound
で全部置き換えたうえで、テストスイートを実行したときにRUBY_ASSERT
が失敗するかどうかをチェックしてみました。
しかしそんな単純な話では終わりませんでした...テストツリーがクリスマスツリーのように赤々と点灯したので、クラッシュレポートに出力されたバックトレースでコードの様子を調べてみたところ、残念ながらentries_bound
とエントリのサイズは必ずしも一致しないことが判明しました。しかもコードにはそのことがしっかりコメントとして残されていたのです。
/* ここでentries_boundを更新してはならない。さもないと、テーブル再構築の前に(配列の)すべてのビンが削除済みエントリ値で埋められてしまう可能性がある。 */
そういうわけで目論見は失敗し、再び設計図に立ち返りました。
それからさらに眉をひそめて見つめているうちに、別のアイデアがひらめきました。
Rubyのハッシュテーブル(ちなみにRubyのSet
はハッシュのSet
です)は、順序を維持します。つまり、順序を維持しないハッシュテーブルと、古典的な配列を組み合わせたものと見なすことも可能です。ハッシュテーブルの値は、単純にその配列にオフセットされます。
ここでハッシュテーブルの部分はst_index_t *bins
、配列の部分はset_table_entry *entries
となります。
どちらのメモリ領域もmalloc
でアロケーションされるので、Set
に要素を追加したり削除すると、それに応じてサイズも拡大または縮小します。
ということは、ハッシュテーブルと配列のどちらか一方のサイズさえわかれば、両方を1回のmalloc
でアロケーションし、他方にアクセスするときは最初のものをスキップすればいけそうです。
この場合、set_table.bins
のサイズはset_table.bin_power
で示されます。
/* Return size of the allocated bins of table TAB. */
static inline st_index_t
set_bins_size(const set_table *tab)
{
return features[tab->entry_power].bins_words * sizeof (st_index_t);
}
かくして、比較的小さなパッチを当てるだけど、まんまとstruct_set_table
の8B
を節約することに成功しました(9250ece)。このおかげで、埋め込みのRTypedData
のサイズを再び32B
に変更したとしても、80B
のスロット内にSet
オブジェクトを保存できるようになりました。
ただし、このパッチによってSet
のパフォーマンスが大幅に悪化しないことを確かめるために、他にもいくつかのベンチマークを実行する必要があります。
🔗 参照キャッシュ
T_STRINGや
T_ARRAYや
T_HASH`といった他の型については、それらのスロットで参照追加用のスペースを確保することはまずありません。そこから、アクセスを高速化して競合を削減する別のアイデアがひらめきました。
この仮定の中核にあるのは、あるオブジェクトのインスタンス変数を参照(look up)するたびに、その次の参照対象が同じオブジェクトになる可能性が高い、というものです。
では、最後に参照したオブジェクトと、それに関連付けられるT_IMEMO/fields
をキャッシュに保存してみてはどうでしょうか?
疑似Rubyコードでは以下のようになります。
module GenericIvarObject
GENERIC_FIELDS_TBL = Hash.new.compare_by_identity
def instance_variable_get(ivar_name)
if ivar_shape = self.shape.find(ivar_name)
fields_obj = if Fiber[:__last_obj__] == self
Fiber[:__last_fields__]
else
Fiber[:__last_obj__] = self
Fiber[:__last_obj__] = RubyVM.synchronize do
GENERIC_FIELDS_TBL[self]
end
end
fields_obj.instance_variable_get(ivar_name)
end
end
end
このキャッシュはfiberローカルのストレージに保存されるので、ロックで保護する必要はありません。
これに関するドラフトパッチ(#14132)をオープンしました。仕上げとベンチマークが必要ではありますが、非常にシンプルな点が気に入っています5。
🔗 今後の作業について
他のケースについては、最終的にRuby VMに適切なコンカレントマップ(concurrent-map)が搭載されて、汎用インスタンス変数テーブルへのロックフリーな参照が可能になるとよいでしょう。しかしコンカレントマップは「難しい」ので、すぐには実現できないかもしれません。
それまでは、T_STRUCT
やT_DATA
などの重要性の高い型については、既にマージ済みだったり近い内にマージが見込まれるソリューションが既にあります。それ以外の方についても、テーブル参照の頻度を減らす方策が既にあります。これらすべてによって、シングルスレッドアプリケーションとマルチRactorアプリケーションの両方でパフォーマンスが向上するため、どちらにもメリットがあります。
Ractorについて私が最も気がかりなのは、いつの日にかRactorのパフォーマンス改善のためにシングルスレッドのパフォーマンスが大きく悪化することになるかもしれない、という点でした。なので、シングルスレッドとマルチRactorの両方でユースケースを改善できる最適化を見つけられたことは、私にとってひときわ喜ばしい気持ちです。
関連記事
-
原注: オブジェクトはfrozenでない限り複数のRactorから参照できないのだから、これは問題にならないのではと思うかもしれません。しかし実際には、
object_id
は本質的にメモ化される(memoized)ため、複数のRactorから参照される可能性はありうるのです。 ↩ - 原注: Euruko 2024でこのテーマを扱った素晴らしい発表の動画があります。 ↩
- 訳注: 88という数字は欧米の極右が使う隠語のひとつです。 ↩
- 原注: 原文の"id'be set."(=うまくいく)はダジャレを狙いました。 ↩
- 訳注: #14132はその後クローズして#14201に置き換えられ、こちらがマージされました。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。