Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RubyのRactorを解き放つ(3)汎用インスタンス変数テーブルの競合を解消する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

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など)
  • ブーリアン(truefalse
  • 静的なシンボル(: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_CLASST_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内部では、mallocfreeによる手動メモリ管理ではなく、GC(ガベージコレクタ)によって管理されているメモリにさまざまなデータを保存するために用いられています。

残りの種別は、 HashArrayStringなどのオブジェクトです。
これらのオブジェクトスロット内のスペースは既に利用済みです。たとえば、String用のスロットは、文字列のlengthcapacityを保存するのに使われており、文字列の長さが十分小さければ、文字列を構成するバイト列自身を保存するのにも使われます。文字列が長い場合は、手動アロケーションされたバッファへのポインタが保存されます。

ただし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_OBJECTT_IMEMO/fieldsの内部にあるのと同様に、参照の配列が存在します。

これは、さまざまな理由から理想的とは言えません。

まず、ハッシュ探索を強いる手法は、T_OBJECTでやっているようなオフセット読み出しや、T_CLASST_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.newData.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::SafeBufferStringのサブクラスで、そこに@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));
  }

Shopify/yjit-bench - GitHub

次に、Shopityのyjit-benchスイートに手を加えてベンチマークループの開始段階でENV["DEB"] = "1"を設定するようにしてみました。今回私が関心を抱いているのは、起動時のコードパスではなく実行時のコードパスだからです。

Shopify/shipit-engine - GitHub

続いて、これも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のインスタンスです。
  • xmlNodexmlDocnokogiri 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_HASHRack::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_tblRB_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_STRINGT_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つ目の8Btyped_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オブジェクトがRTypedDataRDataのどちらかなのかを知っておかなければなりません。

そこで登場するのがtyped_flagです。これはRTypedData構造体内では、RData構造体内にあるdfreeポインタと同じオフセットに配置されます。そしてさまざまな理由から、正当なC関数のポインタが厳密に1に等しくなることは不可能です。

typed_flag1に固定されている理由がこれです。このおかげで、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 = 96B160B程度まで増加し、無駄なメモリ領域を増やしてしまいます。

率直に言えば、アプリケーションがSetを大量に使うのでもない限り大きな問題にはなりませんでしたが、Jeremyは相当悩んでいたようです。

その数週間後にJeremyが思いついたのは、メモリ上の2つのビットをRTypedData.typeRData.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_entriesentries_startentries_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_table8Bを節約することに成功しました(9250ece)。このおかげで、埋め込みのRTypedDataのサイズを再び32Bに変更したとしても、80Bのスロット内にSetオブジェクトを保存できるようになりました。

ただし、このパッチによってSetのパフォーマンスが大幅に悪化しないことを確かめるために、他にもいくつかのベンチマークを実行する必要があります。

🔗 参照キャッシュ

T_STRINGT_ARRAYT_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_STRUCTT_DATAなどの重要性の高い型については、既にマージ済みだったり近い内にマージが見込まれるソリューションが既にあります。それ以外の方についても、テーブル参照の頻度を減らす方策が既にあります。これらすべてによって、シングルスレッドアプリケーションとマルチRactorアプリケーションの両方でパフォーマンスが向上するため、どちらにもメリットがあります。

Ractorについて私が最も気がかりなのは、いつの日にかRactorのパフォーマンス改善のためにシングルスレッドのパフォーマンスが大きく悪化することになるかもしれない、という点でした。なので、シングルスレッドとマルチRactorの両方でユースケースを改善できる最適化を見つけられたことは、私にとってひときわ喜ばしい気持ちです。

関連記事

RubyのRactorを解き放つ(1)object_idの改修(翻訳)

RubyのRactorを解き放つ(2)クラスインスタンス変数の競合を解消する(翻訳)

Ruby: メモ化のイディオムが現代のRubyパフォーマンスに与える影響(翻訳)


  1. 原注: オブジェクトはfrozenでない限り複数のRactorから参照できないのだから、これは問題にならないのではと思うかもしれません。しかし実際には、object_idは本質的にメモ化される(memoized)ため、複数のRactorから参照される可能性はありうるのです。 
  2. 原注: Euruko 2024でこのテーマを扱った素晴らしい発表の動画があります。 
  3. 訳注: 88という数字は欧米の極右が使う隠語のひとつです。 
  4. 原注: 原文の"id'be set."(=うまくいく)はダジャレを狙いました。 
  5. 訳注: #14132はその後クローズして#14201に置き換えられ、こちらがマージされました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。