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

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

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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

Ractorに関する過去記事では、アプリケーション全体を1つのRactor内で実行できる可能性は低いと私が考えている理由と、それでもRactorは、状況によっては、CPUバウンドの処理をメインスレッドから追い出して一部のパラレルアルゴリズムを有効にするうえで非常に有効であると思われる理由について説明しました。

しかし同記事で既に述べたように、残念ながら現時点のRactorではまだ実現できません。Ractorにはインタプリタをクラッシュさせる既知のバグが多数残っています。また、Ractor同士はパラレル実行が想定されているにもかかわらず、Ruby VMには単一の(真の意味での)グローバルロックがまだ存在していて、Ractorがある種の操作を実行する場合はそのロックを取得する必要があるため、同等のシングルスレッドのコードよりもパフォーマンスが低下しがちです。

残る競合ポイントの1つは、クラスインスタンス変数とクラス変数です。コードでは、クラスやモジュールのインスタンス変数をある種の設定としてチェックすることが非常に多いため、この競合ポイントはRactorのパフォーマンスに著しく影響を及ぼす可能性があります。

module Mod
  @a = @b = @c = 1

  def self.compute(count)
    count.times do
      @a + @b + @c
    end
  end
end

ITERATIONS = 1_000_000
PARALLELISM = 8

if ARGV.first == "ractor"
  ractors = PARALLELISM.times.map do
    Ractor.new do
      Mod.compute(ITERATIONS)
    end
  end
  ractors.each(&:take)
else
  Mod.compute(ITERATIONS * PARALLELISM)
end

このシンプルなマイクロベンチマークで行っているのは、モジュールの3つのインスタンス変数を繰り返し加算することだけです。あるモードではメインスレッドで逐次的に実行されますが、引数に"ractor"が渡されると、同じ回数のループを8つのRactorでパラレルに実行します。すなわち、理想的な環境ではRactorブランチはほぼ8倍高速になるはずです。

しかし、このベンチマークをRubyのmasterブランチで実行しても、そのような結果は得られません。

$ hyperfine -w 1 './miniruby --yjit ../test.rb' './miniruby --yjit ../test.rb ractor'
Benchmark 1: ./miniruby --yjit --disable-all ../test.rb
  Time (mean ± σ):     252.4 ms ±   1.2 ms    [User: 250.2 ms, System: 1.6 ms]
  Range (min ... max):   249.9 ms ... 253.8 ms    11 runs

Benchmark 2: ./miniruby --yjit --disable-all ../test.rb ractor
  Time (mean ± σ):      2.005 s ±  0.013 s    [User: 2.098 s, System: 6.963 s]
  Range (min ... max):    1.992 s ...  2.027 s    10 runs

Summary
  ./miniruby --yjit ../test.rb ran
    7.94 ± 0.06 times faster than ./miniruby --yjit ../test.rb ractor

見ての通り、Ractorを使うブランチは8倍高速化するどころか8倍遅くなってしまいました。その理由は、モジュールやクラスインスタンス変数を読み出すたびに、セカンダリのRactorがVMロックを取得するためです。この操作自体も高コストですが、さらに悪いことにロックを取得するまでにかなりの時間待たされる羽目になります。

何か打つ手があるでしょうか?

🔗 言語のセマンティクス

このロックを削除・削減する方法を詳しく説明する前に、そもそもクラスインスタンス変数がRactorでどのように振る舞うかをおさらいしておきましょう。

クラスはグローバルなので、インスタンス変数も同様にグローバルです。つまりどちらも本質的にグローバルな存在なのです。
そのため、Ractorでは行えない操作というものがどうしても発生してしまいます。それを回避しようとすると、Ractorによる分離まで回避されてしまう可能性もあります。

ルール1: クラスインスタンス変数に値を設定することが許されるのはメインのRactorだけである。

class Test
  class << self
    attr_accessor :var
  end
end

Test.var = 1 # 動作する

Ractor.new do
  # 動作する
  p Test.var

  # エラー: Ractor::IsolationError
  # メインでないRactorはクラスやモジュールのインスタンス変数を
  # 改変できません
  Test.var = 2
end.take

つまり、2つ目以降のractorは、クラスやモジュールのインスタンス変数を読み取ることはできますが、書き込むことは許されません。

ルール2: Ractorがクラスのインスタンス変数を読み取ることを許されるのは、その変数に保存されているオブジェクトが共有可能(sharable)な場合だけである。

class Test
  class << self
    attr_accessor :var1, :var2
  end

  @var1 = {}.freeze
  @var2 = {}
end

Ractor.new do
  # 動く:
  p Test.var1

  # エラー: Ractor::IsolationError
  # メインでないRactorはクラスやモジュールの
  # 「共有不可」インスタンス変数を取得できません
  p Test.var2
end.take

🔗 競合ポイントを削減する

ロックが競合する問題に対処するときに最初に試みられるソリューションといえば、大きなロックを複数の細かいロックに分割することです。
上述のシンプルなベンチマークでは、すべてのractorが同一モジュール上の変数にアクセスしているため、この方法は通用しませんが、現実のシナリオでは、さまざまなモジュール上の変数にアクセスすることが多いため、同一モジュール上の変数アクセスにおける競合はさほど起きないと思われます。

しかし、私がRactorで想定している現実的なユースケースでは、少なくとも最初のうちは、以下のようなfutureパターンに近いAPIで小さな多数のコード片をパラレルに実行することです。

futures = []
futures << Ractor.new { fetch_and_compute_prices }
futures << Ractor.new { fetch_and_compute_order_history }
...
futures.map(&:take)

この場合は、Ractorが同一のモジュール変数やクラス変数に繰り返しアクセスすることが多くなると予想されるため、粒度の細かいロックを導入してもあまり嬉しくなりません。

別の方法として、readers-writersロックを使う方法も考えられます。これは、変数への「書き込み」が許されるのはメインのRactorのみとすることで、それ以外のすべてのセカンダリRactorは読み取り用のロックを同時に取得できるようになります。

しかし過去の経験から、readers-writersロックを使えばコンカレントな読み取りスレッド同士がストールすることを防げるものの、すべてのスレッドは同一の値の増分や減分をアトミックに行わなければならなくなり、これはCPUキャッシュで不都合なので、競合発生時のコストはやはり非常に大きくなります。
readers-writersロックは、保護対象となる操作が比較的低速の場合は良好なのですが、このユースケースではインスタンス変数の読み取りコストが極めて小さいため、どのような種類のロックを使おうとも(競合を伴わないロックであろうと)たちまちコストに見合わなくなり、パフォーマンスが急減します。

そういうわけで、合理的なソリューションを得るには「ロックを一切使わない」方法を模索するしかありません。

🔗 インスタンス変数のしくみ

Rubyのインスタンス変数をロックフリーにする方法を理解するためには、まずインスタンス変数のしくみから理解しておかなければなりません。
いつものように疑似Rubyコードで説明したいと思います。まずはインスタンス変数の読み取りです。

class Module
  def instance_variable_get(variable_name)
    if RubyVM.main_ractor?
      # メインのractorだけがインスタンス変数への書き込みを許される
      # つまり他の何者も`@shape`や`@fields`を同時に変更できない
      # ことがわかっているので、ここではロックは不要
      if field_index = @shape.field_index_for(variable_name)
        @fields[field_index]
      end
    else
      # セカンダリのractorは、読み取りであってもVMをロックしておかなければならない
      # (メインのractorが`@shape`や`@fields`を改変する可能性があるため)
      RubyVM.synchronize do
        if field_index = @shape.field_index_for(variable_name)
          value = @fields[field_index]
          raise Ractor::IsolationError unless Ractor.shareable?(value)
          value
        end
      end
    end
  end
end

シェイプ(shape)のしくみについては過去記事で何度も説明しているので本記事では説明しません。ここで唯一知っておくべきなのは、これらのインスタンス変数は連続配列に保存されることと、シェイプは変数の保存場所を表すオフセット値をトラッキングしていることです。さらにこれらはイミュータブル(改変不可)なので、コンカレントにアクセスしても大丈夫です。

その結果、インスタンス変数の読み出し操作は、シェイプのツリーに少量のクエリ(ある変数が存在しているかどうかを調べ、存在する場合はその変数への添字がどんな値かを調べる)を実行するだけで済みます。その後、オブジェクトの@fields配列内のオフセットを指定して実際に変数を読み取ります。

ただし、他のセカンダリRactorでは、シェイプとフィールドが改変されないようにするためのVMロックも必要です。
インスタンス変数への書き込みのしくみがわかると、この点がより明確に見えてきます。

class Module
  def instance_variable_set(variable_name, value)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    RubyVM.synchronize do
      if field_index = @shape.field_index_for(variable_name)
        # 変数が既に存在するので、値を置き換える
        @fields[field_index] = value
      else
        # 変数が存在しないので、シェイプを遷移する必要がある
        next_shape = @shape.add_instance_variable(variable_name)

        if next_shape.capacity > @shape.capacity
          # @fieldsが満杯なので、より大きなアロケーションが必要
          new_fields = Memory.allocate(size: next_shape.capacity)
          new_fields.replace(@fields) # 中身をコピーする
          @fields, old_fields = new_fields, @fields

          # fields配列は手動管理されたメモリなので
          # 明示的に解放する必要がある
          Memory.free(old_fields)
        end

        @fields[next_shape.field_index] = value
        @shape = next_shape
      end
    end
  end
end

ご覧のように、fields配列はサイズが指定されているので、インスタンス変数を追加したときは、アロケーションをより大きいアロケーションに差し替えて、さらにオブジェクトのシェイプも変更する必要があります。

これが、VMロックが必要な理由です。この操作中に他のractorがインスタンス変数を読み取ると、以下のようなさまざまな競合状態が発生する可能性があるため、この操作中は他のractorからインスタンス変数を読み取れません。

  • メモリ解放中にold_fieldsの内容が読み取られると、「use-after-free」バグが発生する可能性がある
  • 新しいシェイプを利用中にnew_fieldsの内容が読み取られると、「範囲外メモリ読み取り」が発生する可能性がある
  • 新しいシェイプを利用中にnew_fieldsの内容が読み取られると、新しい値が書き込まれる前に「未初期化メモリ読み取り」が発生する可能性がある

C言語などの低レベルプログラミング言語に慣れていない方には、私の言っていることが大げさに思えるかもしれません。実際のところ、シェイプの更新は最後に行われる操作なので、ケース2とケース3の問題は発生しません。

しかしここで少々残念なお知らせがあります。

🔗 メモリモデル

マルチスレッドプログラミングは厄介なものです。プロセッサにはさまざまなキャッシュ機構が備わっているので、変数はいつもRAM上の同じ場所に存在しているとは限りません。そのため、複数のスレッドに同一メモリ領域の読み書きを許してしまうと、さらに厄介なことになります。

値はCPUのL1/L2キャッシュなどにコピーされていることもあれば、CPUレジスタにコピーされている可能性もあります。あるスレッドが書き込んだ変数は、直ちに他のすべてのスレッドから見えるわけではなく、書き込みがRAMに反映されるまで時間がかかります。
さらに悪いことに、複数の変数をある順序で書き込んだ場合、他のスレッドがその変数を書き込んだとおりの順序で認識できる保証すらないのです。

以下のシンプルなマルチスレッドプログラムで考えてみましょう。

Point = Struct.new(:x, :y)

treasure = nil

thread = Thread.new do
  while true
    if treasure
      puts "Treasure is at #{treasure.x.inspect} / #{treasure.y.inspect}"
      break
    end
  end
end

point = Point.new
point.x = 12
point.y = 24
treasure = point

thread.join

Rubyプログラマーなら、このプログラムを見ればTreasure is at 12 / 24が出力されると予測するでしょうし、実際その通りになります。結局のところ、 Pointのインスタンスを完全に初期化し終わってから、treasureグローバル変数を更新してこのインスタンスを指すようにしているのです。

しかし同じようなプログラムをC言語で書くと、出力は以下のいずれかになります。

  • Treasure is at 12 / 24
  • Treasure is at nil / 24
  • Treasure is at 12 / nil
  • Treasure is at nil / nil

理由は、メモリモデルが関わってくるからです。
コンパイラは、場合によっては最適化のためにメモリの読み書きの順序を変更することがあります。そのため、プログラムを正しく書くには、コンパイラが何を実行でき、何を実行できないかを知っておかなければなりません。それを定義するのがメモリモデルです。
C言語のメモリモデルは非常にユルいので、コンパイラは読み書きの順序をかなりの程度まで変更することが許されています。

これはコンパイラだけの問題ではなく、CPUも読み書き操作の順序を変えてくる可能性があります。x86(インテル)CPUのメモリモデルはかなり厳格なので順序変更はあまり発生しません。しかしarm64 CPUのメモリモデルはもっとユルいので、CPUが順序を変更して実行されると結果が期待通りにならない可能性もあります。

CコンパイラやCPUでは、この問題を回避するためにバリア(barrier)というしくみを提供しています。コードにバリアを挿入すると、読み書きの順序がバリアを超えて変更されないようになり、これによってすべてのスレッドからのメモリ参照が一貫するようになります。

🔗 アトミック書き込み

この機能は、プログラマーの観点からは「"アトミック"読み込み」や「"アトミック"書き込み」という操作として公開されるのが一般的です。コンパイラやCPUは、「メモリ操作の順序はアトミックな操作を越えて変更してはならない」と理解します。

instance_variable_setの実装の話に戻ると、このアトミック書き込みを使うことで、3つの競合状態のうち2つを解消できます。

class Module
  def instance_variable_set(variable_name, value)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    if field_index = @shape.field_index_for(variable_name)
      # 変数が既に存在するので、値を置き換える
      @fields[field_index] = value
    else
      # 変数が存在しないので、シェイプを遷移する必要がある
      next_shape = @shape.add_instance_variable(variable_name)

      if next_shape.capacity > @shape.capacity
        # @fieldsが満杯なので、より大きなアロケーションが必要
        new_fields = Memory.allocate(size: next_shape.capacity)
        new_fields.replace(@fields) # 中身をコピーする
        old_fields = @fields
        # @fieldsが満杯になるまで@fieldsが更新されないようにする
        Atomic.write { @fields = new_fields }

        # fields配列は手動管理されたメモリなので
        # 明示的に解放する必要がある
        Memory.free(old_fields)
      end

      @fields[next_shape.field_index] = value
      @shape = next_shape
    end
  end
end

このシンプルな変更によって、新しい@shapeが他のスレッドから見えるようになってから初めて、@fieldsが他のスレッドから見えるようになります。

古い@shapeに新しい@fieldsが存在しているように見えるかもしれませんが、@shapeのすべてのオフセットが指すものが同じ値を含んでいるのは構わないので、これは許容されます。素晴らしいですね。
後は「use-after-free」問題の解決方法を見つけるだけです。

🔗 ガベージコレクタを活用する

ここでの問題は、古い@fieldsを新しい@fieldsに差し替えた後で、メモリリークを防ぐために古いメモリを解放しなければならないことです。しかし同期が行われていない場合は、レジスタやキャッシュに残っている古い配列を他のスレッドが参照しないようにすることを保証できません(古い配列を解放した後で読み込もうとするとセグメンテーション違反が発生する可能性があります)。

つまり、古い配列への参照がなくなるまでじっと待ち続けてからでないと古い配列を解放してはならないということになります。しかし考えてみれば、これはまさにガベージコレクタ(GC)がやっていることです。うまいことにRubyにはGCが既にあります。

すなわち、「use-after-free」問題を回避するには、手動でアロケーションしたメモリの代わりにRubyのArrayをそのまま使えばよいのです。これでメモリを明示的に解放する必要がなくなり、ガベージコレクタが代わりに引き受けてくれます。

class Module
  def instance_variable_set(variable_name, value)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    if field_index = @shape.field_index_for(variable_name)
      # 変数が既に存在するので、値を置き換える
      @fields[field_index] = value
    else
      # 変数が存在しないので、シェイプを遷移する必要がある
      next_shape = @shape.add_instance_variable(variable_name)

      if next_shape.capacity > @shape.capacity
        # @fieldsが満杯なので、より大きなアロケーションが必要
        new_fields = Array.new(next_shape.capacity)
        new_fields.replace(@fields) # 中身をコピーする
        old_fields = @fields
        # @fieldsが満杯になるまで@fieldsが更新されないようにする
        Atomic.write { @fields = new_fields }
      end

      @fields[next_shape.field_index] = value
      @shape = next_shape
    end
  end
end

これで、古い@fieldsの内容を別のスレッドが読み込んでいる最中であっても、他からの参照がすべてなくなったことをガベージコレクタが検出するまではメモリが有効なので、問題ありません。

かくして、クラスインスタンス変数を完全にロックフリーな形で読み書きできるようになったのです!

...となればよかったのですが、そうはいきませんでした。見落としていた複雑な問題が2つあったのです。

🔗 1: インスタンス変数の削除に対処する

レアな情報なのでご存じないかもしれませんが、実は、Rubyオブジェクトのインスタンス変数はその気になれば削除できるのです。

class Test
  p instance_variable_defined?(:@foo) # => false
  @foo = 1
  p instance_variable_defined?(:@foo) # => true

  remove_instance_variable(:@foo)
  p instance_variable_defined?(:@foo) # => false
end

インスタンス変数を削除する操作は極めて珍しいのですが、起きる可能性はあるため、インスタンス変数の削除はスレッド安全に行わなければなりません。

以下の疑似実装を見てみましょう。

class Module
  def remove_instance_variable(variable_name)
    removed_index = @shape.field_index_for(variable_name)

    # 当初この変数は存在していなかった
    return unless removed_index

    next_shape = @shape.remove_instance_variable(variable_name)

    # fieldsを左にシフトする
    removed_index.upto(next_shape.fields_count) do |index|
      @fields[index] = @fields[index + 1]
    end

    @shape = next_shape
  end
end

つまり、インスタンス変数を削除すると、削除前よりもサイズが小さなシェイプができます。つまり、インスタンス変数の削除後はすべての変数の添字の値が減るので、すべてのフィールドをシフトする必要があります。

わかりやすくするために、以下のコードを考えてみましょう。

@a = 1
@b = 2
@c = 3
remove_instance_variable(:@b)

上のスニペットでは、@fields[1, 2, 3]から[1, 3]に変更されています。この操作をRubyの配列でスレッド安全に実行するのは不可能です。

もちろん、@fieldsのコピーを作成してそれをシフトしてから、@fieldsをアトミックに差し替えるという方法も一応考えられますが、古いシェイプと新しいシェイプは根本的に互換性がないという大問題が残ってしまいます。

古いフィールドを使っている@cに、新しいシェイプでアクセスすると、2という誤った結果になります。

逆に新しいフィールドを使っている@cに、古いシェイプでアクセスすると配列境界を超えてしまい、おそらくセグメンテーション違反が発生するでしょう。

ここで、すべてのractorのインスタンス変数の見え方が変わらないようにしようとすると、書き込み順序を工夫する手法が使えません。

なお、Rubyのオブジェクトシェイプの初期実装はそうなっていませんでした。

Ruby 3.2の開発初期段階では、#remove_instance_variableを実行しても生成されるシェイプのサイズは小さくならず、代わりにUNDEF型の子シェイプを生成していました。これは、オフセット1が未定義であるとみなす必要があるという情報を記録します。

しかしこの方法では、振る舞いの不正なコードによってシェイプが無限に生成されてしまう可能性があることが判明しました。

obj = Object.new
loop do
  obj.instance_variable_set(:@foo)
  obj.remove_instance_variable(:@foo)
end

そこで実装を変更して、シェイプツリーをリビルドするようになったのです(#6866)。

変更前の実装のままだったら競合状態を防げたはずなので、この場合はその方が好都合だったかもしれません。
しかし最終的には、まだ話していなかったもう1つの複雑な問題の方がよほど深刻だったので、変更前の実装であろうとなかろうと最終的には同じようなものでした。

🔗 2:「複雑なシェイプ」問題

ここまであえて説明してこなかったもう1つの問題、それは「複雑なシェイプ」の存在でした。

Rubyのシェイプは追加(append)のみが可能なので、インスタンス変数の定義順序が場合によって異なっていたり、インスタンス変数を削除するコードが多用されていたりすると、シェイプの組み合わせが無数に生成され、それぞれのシェイプがある程度のメモリを消費してしまいます。

そのためRubyでは、指定のクラスがシェイプのバリエーションをいくつ生成するかをトラッキングし、その個数が一定の閾値(現在は8)を超えると、そのクラスは"too complex"とマーキングされるようになっています。

以下のRubyスクリプトを実行すると、パフォーマンスに関する警告が表示されます。

Warning[:performance] = true

class TooComplex
  def initialize
    10.times do |i|
      instance_variable_set("@iv_#{i}", i)
      remove_instance_variable("@iv_#{i}")
    end
  end
end

TooComplex.new
/tmp/complex.rb:6: warning: The class TooComplex reached 8 shape variations,
instance variables accesses will be slower and memory usage increased.
It is recommended to define instance variables in a consistent order,
for instance by eagerly defining them all in the #initialize method.

シェイプのバリエーション数が閾値を超えると、そのクラスのインスタンスで生成されるシェイプが、「シングルトン」シェイプとでも言うべきものに変わります。このシングルトンシェイプは、配列ではなくHashに保存されます。
シングルトンシェイプは遅いうえにメモリを余計に消費しますが、新しいシェイプの作成回数は制限されます。

そういうわけで、現実の#instance_variable_get#instance_variable_setの実装は、本記事冒頭で説明したものよりも複雑になっています。実際の実装は以下のような感じになっています。

class Module
  def instance_variable_get(variable_name)
    if @shape.too_complex?
      @fields[variable_name] # @fieldsはHash
    elsif field_index = @shape.field_index_for(variable_name)
      @fields[field_index] # @fieldsはArray
    end
  end

  def instance_variable_set(variable_name, value)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    if shape.too_complex?
      return @field_index[variable_name] = value
    end

    if field_index = @shape.field_index_for(variable_name)
      # 変数が既に存在するので、値を置き換える
      @fields[field_index] = value
    else
      # 変数が存在しないので、シェイプを遷移する必要がある
      next_shape = @shape.add_instance_variable(variable_name)

      if next_shape.too_complex?
        new_fields = {}
        @shape.each_ancestor do |shape|
          new_fields[shape.variable_name] = @fields[shape.field_index]
        end

        @fields = new_fields
        @shape = next_shape

        return @fields[variable_name] = value
      end

      if next_shape.capacity > @shape.capacity
        # @fieldsが満杯なので、より大きなアロケーションが必要
        new_fields = Array.new(next_shape.capacity)
        new_fields.replace(@fields) # 中身をコピーする
        old_fields = @fields
        # @fieldsが満杯になるまで@fieldsが更新されないようにする
        Atomic.write { @fields = new_fields }
      end

      @fields[next_shape.field_index] = value
      @shape = next_shape
    end
  end
end

複雑なシェイプは通常のシェイプと大きく異なっているため、このコードは競合状態であふれかえっています。新しい変数を追加するだけというハッピーパスを通る場合であっても、@fieldsが配列からHashに変更される可能性があります。
つまり、@shape@fieldsの同期が完璧でなかったとすると、配列をHashとしてアクセスしたり、逆にHashを配列としてアクセスしたりすることで、VMがクラッシュする可能性があります。

🔗 64ビットのアトミック処理が128ビットになる

@shape@fieldsへの書き込みをアトミックにするという解決方法も考えたのですが、残念ながらこの場合は実際には不可能です。

理由1: 1回のアトミック操作のために、ポインタサイズの値(64ビット)を2つも書き込まなければならなくなります。SIMDインストラクションが使える一部の最新CPUなら可能ですが、Rubyは多くのプラットフォームをサポートしているので、この方法ではすべてのプラットフォームをサポートできません。

理由2: この2つのフィールドは、メモリ上に連続して配置されなければならないという制約があります。互いに離れた位置にある2つのポインタサイズ値をアトミックに書き込むことはできません(連続して配置された2つの64ビット値は、意味上は1個の128ビット値になりますが、これ以上立ち入らないことにします)。とにかく、@shape@fieldsはメモリ上に連続して配置できないのです。

🔗 委譲

そのとき、@shape@fieldsをそれぞれ別個のGC管理オブジェクトにバンドルしてみてはどうだろうというアイデアを思いつきました。これなら、2つのフィールドをどちらもアトミックに更新する必要が生じたときに、2つのフィールドのコピーを作って作業し、終わったらポインタを差し替えればよいのです。

class Module
  def instance_variable_get(variable_name)
    @fields_object&.instance_variable_get(variable_name)
  end

  def instance_variable_set(variable_name, value)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    new_fields_object = @fields_object ? @fields_object.dup : Object.new
    new_fields_object.instance_variable_set(variable_name, value)
    Atomic.write { @fields_object = new_fields_object }
  end

  def remove_instance_variable(variable_name)
    raise FrozenError if frozen?
    # メインのractorだけがインスタンス変数への書き込みを許される
    raise Ractor::IsolationError unless RubyVM.main_ractor?

    new_fields_object = @fields_object ? @fields_object.dup : Object.new
    new_fields_object.remove_instance_variable(variable_name)
    Atomic.write { @fields_object = new_fields_object }
  end
end

これなら実に簡単です。インスタンス変数を従来のようにクラスやモジュールに保存するのではなく、通常のObjectに保存しておき、インスタンス変数を改変する必要が生じたら、最初に現在のステートのクローンを作成してから、安全でない改変操作を実行し、最後に@fields_objectの参照をアトミックに差し替えます。

もちろん、この通りに実装するとオブジェクトのアロケーションが莫大なものになってしまうので、実際のコードでは、既存のオブジェクトを直接改変するのではなく、オブジェクトが安全なときを見計らってコピーを取るという特殊ケースを多数追加しています。しかし概念としては、私が作った現在のパッチ(989bce8)でやっていることとまったく同じです。

このパッチはもっぱら概念実証用です。理由はいろいろありますが、最終的にはこのパッチで示したT_OBJECTを使うべきではないと思っています。しかしこのパッチのフォローアップとして、T_OBJECTT_IMEMOに置き換えたものが既にあります。このT_IMEMOは、Rubyユーザーからは見えない内部型です。

このソリューションを得たおかげで、クラスインスタンス変数にかかわるロックを削除することに成功しました。そして例のマイクロベンチマークのRactor版は、シングルスレッド版のほぼ3倍近く高速になったのです。

$ hyperfine -w 1 './miniruby --yjit ../test.rb' './miniruby --yjit ../test.rb ractor'
Benchmark 1: ./miniruby --yjit ../test.rb
  Time (mean ± σ):     166.3 ms ±   1.1 ms    [User: 164.4 ms, System: 1.5 ms]
  Range (min ... max):   164.0 ms ... 168.5 ms    18 runs

Benchmark 2: ./miniruby --yjit ../test.rb ractor
  Time (mean ± σ):      59.3 ms ±   2.6 ms    [User: 211.4 ms, System: 1.5 ms]
  Range (min ... max):    57.9 ms ...  67.7 ms    48 runs

Summary
  ./miniruby --yjit ../test.rb ractor ran
    2.80 ± 0.12 times faster than ./miniruby --yjit ../test.rb

皆さんお待ちかねの8倍高速にはまだまだ届いていないものの、プロファイリングによると、これはスケジューラの問題であることが示されているので、これも最終的に修正する予定ですが、現状でもRuby 3.4の13倍高速なのです。

$ hyperfine -w 1 'ruby --disable-all --yjit ../test.rb ractor' './ruby --disable-all --yjit ../test.rb ractor'
Benchmark 1: ruby --disable-all --yjit ../test.rb ractor
  Time (mean ± σ):     772.3 ms ±   9.0 ms    [User: 1023.8 ms, System: 1325.6 ms]
  Range (min ... max):   759.3 ms ... 790.5 ms    10 runs

Benchmark 2: ./ruby --disable-all --yjit ../test.rb ractor
  Time (mean ± σ):      56.8 ms ±   1.4 ms    [User: 205.7 ms, System: 1.6 ms]
  Range (min ... max):    55.8 ms ...  65.6 ms    50 runs

Summary
  ./ruby --disable-all --yjit ../test.rb ractor ran
   13.59 ± 0.36 times faster than ruby --disable-all --yjit ../test.rb ractor

うまくいけば、今後数週間以内にこれをマージできると思います。

🔗 「メモリ使用量が増えるのでは?」

「なるほど、実に結構です。しかしクラスやモジュールのインスタンス変数を保存するオブジェクトを増やしたら、その分Rubyのメモリ使用量が増えそうなんですけど」

まあたぶんそうはならないでしょう。従来の@fieldsメモリはmallocで管理されています。これは利用中のmallocの実装に依存しているのですが、殆どの場合、アロケーションされたポインタ1個につき16Bのオーバーヘッドにとどまるでしょう。これはRubyオブジェクトのオーバーヘッドときっかり同じです。

したがって、全般的にはメモリ使用量は増加しないはずです。

🔗 もう1つ嬉しい点

このソリューションには、思わぬご利益もありました。
最近マージされた新しい名前空間機能(#21311)に伴うバグやパフォーマンス問題の再発が、このソリューションによってどちらも修正されたのです。

名前空間内にあるクラスのインスタンス変数セットとfrozenステータスは名前空間ごとに異なるはずですが、これがシェイプではまったく機能していませんでした。理由は、現時点のシェイプがオブジェクトのヘッダーに保存されていて、クラスやモジュールを含むどのオブジェクトもシェイプを1個ずつしかもっていないためでした。

インスタンス変数の管理を別オブジェクトに委譲したことで、クラスが名前空間ごとに@fields_objectを1つずつ持てるようになり、それによってシェイプとフィールドを両方とも持てるようになり、クラスインスタンス変数が適切に名前空間化されるようになったというわけです。

この効果を狙った変更のつもりではありませんでしたが、嬉しい副作用でした。

関連記事

Rubyオブジェクトの未来をつくる「シェイプ」とは(翻訳)

Ruby: オブジェクトシェイプに優しいコードの書き方(翻訳)


CONTACT

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