Closed17

So You Want To Remove The GVL? を読む

dak2dak2

I’m personally not really in favor of removing the GVL, I don’t think the tradeoff is quite worth it, at least not yet, nor do I think it would be as much of a game-changer as some may imagine.
If it didn’t have any impact on the classic (mostly) single-threaded performance, I wouldn’t mind it, but it is almost guaranteed to degrade single-threaded performance significantly, hence this feels a bit like “a bird in the hand is worth two in the bush” kind of proposition.

byroot としては賛成してないし、シングルスレッドのパフォーマンスを低下させるのはほぼほぼ確実だという立場

https://byroot.github.io/ruby/performance/2025/01/29/so-you-want-to-remove-the-gvl.html

dak2dak2

For quite a long time, it has been said that Rails applications are mostly IO-bound, hence Ruby’s GVL isn’t that big of a deal and that has influenced the design of some cornerstone pieces of Ruby infrastructure like Puma and Sidekiq.

Rails アプリケーションはほとんど I/O バウンドな処理が占めている
=> DB へのレコード取得や外部 API 呼び出しなどの処理

I/O バウンドなワークフローだとそもそもブロッキングされるから、GVL によるボトルネックになる局面があまりないということかな

dak2dak2

As I explained in a previous post, I don’t think it’s quite true for most Rails applications. Regardless, the existence of the GVL still requires these threaded systems to use fork(2) in order to exploit all the cores of a server: one process per core. To avoid all this, some people have been calling for the GVL to simply be removed.

GVL だとサーバーのすべてのコアを利用するために fork(2) を使用するので、それを避けるためにGVL の削除を求める人もいるとか

プロセスごとに GVL を所有するから fork(2) でプロセス増やして並列実行できるようにすることでスループットを上げるということだと思われ
https://www.speedshop.co/2020/05/11/the-ruby-gvl-and-scaling.html

なんで避けたいのか

プロセスが増えるとメモリ空間が分離されているからメモリ使用量が増えてお金かかるというのはありそう
コンテキストスイッチのオーバーヘッドもあるのかな
あまり具体的にイメージができていないが

dak2dak2
QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  quoted = QUOTED_COLUMN_NAMES[name]

  # Ruby could switch threads here

  if quoted
    quoted
  else
    QUOTED_COLUMN_NAMES[name] = quote(name)
  end
end

上記コードは、GVL がある場合でも race conditions、厳密に言うと、check-then-act race condition の影響を受ける
ただ、結果が冪等ならそんな問題じゃないよねといってる、確かに

dak2dak2

check-then-act race condition

Check-then-act. This race condition happens when two processes check a value on which they will take each take an external action. The processes both check the value, but only one process can take the value with it. The later-occurring process will read the value as null. This results in a potentially out-of-date or unavailable observation being used to determine what the program will do next. For example, if a map application runs two processes simultaneously that require the same location data, one will take the value first so the other can't use it. The later process reads the data as null.

https://www.techtarget.com/searchstorage/definition/race-condition

要はプロセスごとに読み取った値が違うので、後続のプロセスでその値を元に書き込みが行われた場合に意図しないデータに書き変わったりする可能性があるみたいな話か

QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  quoted = QUOTED_COLUMN_NAMES[name]

  # Ruby could switch threads here

  if quoted
    quoted
  else
    QUOTED_COLUMN_NAMES[name] = quote(name)
  end
end

先ほどの例だと、

スレッド 実行内容
T1 quoted = QUOTED_COLUMN_NAMES[name] を取得
T1 ここで T2 にスイッチ(プリエンプション発生)
T2 quoted = QUOTED_COLUMN_NAMES[name] を取得
T2 QUOTED_COLUMN_NAMES[name] = quote(name)(値をセット)
T2 ここで T1 にスイッチ
T1 QUOTED_COLUMN_NAMES[name] = quote(name)2回目の quote 実行!

T2 で2回目の quote を実行した際の値が T1 と異なる場合は、意図しない値に書き変わってしまう可能性がある
=> 冪等であれば問題ない

dak2dak2
QUOTED_COLUMN_NAMES = 20.times.to_h { |i| [i, i] }

def quote_column_name(name)
  QUOTED_COLUMN_NAMES[name] ||= "`#{name.to_s.gsub('`', '``')}`".freeze
end

threads = 4.times.map do
  Thread.new do
    10_000.times do
      if quote_column_name("foo") != "`foo`"
        raise "There was a bug"
      end
      QUOTED_COLUMN_NAMES.delete("foo")
    end
  end
end

threads.each(&:join)

上記のコードは同じように race condition があるものの Ruby 上では動く
JRuby / TruffleRuby などの GVL がない環境では、並列実行されるので NullPointerException が起きる

QUOTED_COLUMN_NAMES.delete("foo")

任意のスレッドが Hash の foo キーを削除した後に、別のスレッドで QUOTED_COLUMN_NAMES["foo"] を実行しようとすると、アクセスできずに NullPointerException が起きる

JVM だと GVL がないのでそのままだと並列実行されるためスレッドセーフでない

dak2dak2
require 'concurrent'

QUOTED_COLUMN_NAMES = Concurrent::Map.new

def quote_column_name(name)
  QUOTED_COLUMN_NAMES.compute_if_absent(name) { "`#{name.to_s.gsub('`', '``')}`".freeze }
end

threads = 4.times.map do
  Thread.new do
    10_000.times do
      if quote_column_name("foo") != "`foo`"
        raise "There was a bug"
      end
      QUOTED_COLUMN_NAMES.delete("foo")
    end
  end
end

threads.each(&:join)

Concurrent::Map は スレッドセーフな Hash を提供できるので、これを使うと良い

concurrent-ruby gem

Modern concurrency tools for Ruby. Inspired by Erlang, Clojure, Scala, Haskell, F#, C#, Java, and classic concurrency patterns.

https://github.com/ruby-concurrency/concurrent-ruby?tab=readme-ov-file

dak2dak2

But that’s not the only way to remove the GVL, another way that is often envisioned is to replace the one global lock, by a myriad of small locks, one per every mutable object.
In terms of work needed, it’s fairly similar to the previous approach, you’d need to go over all the C code and insert explicitly lock and unlock statements whenever you touch a mutable object. It would also require some space on every object, likely a bit more than just a counter though.
With such approach, C extensions would still likely need some work, but pure Ruby code would remain fully compatible.
If you’ve heard about the semi-recent effort to remove Python’s GIL (that’s what they call their GVL), that’s the approach they’re using. So let’s look at the sort of changes they made, starting with their base object layout that is defined in object.h

Python は GIL を削除しようとしてるのか。
mutable なオブジェクトごとに明示的にロック・アンロックをすると。
後の文章でオブジェクトごとにロック機構を導入すると、メモリオーバーヘッドが増えるよねって書いてる

dak2dak2

In the case of Python, from my understanding, the driving force behind the effort to remove the GIL is mostly the machine learning community, in big part, because feeding graphic cards efficiently requires a fairly high level of parallelism, and fork(2) isn’t very suitable for it.

Python GIL 削除のモチベは ML のためっぽい
fork(2) だと Copy on Write だからデータ量が多いと、オーバーヘッドになるのかな

dak2dak2

But, again from my understanding, the Python Web community, such as Django users, seem to be content with fork(2), even though Python is at a major disadvantage over Ruby in terms of Copy-on-Write effectiveness, because as we saw previously, its reference counting implementation means most objects are constantly written to, so CoW pages are very quickly invalidated.
On the other hand, Ruby’s mark-and-sweep GC is much more Copy-On-Write friendly, as almost all the GC tracking data isn’t stored in the objects themselves but inside external bitmaps. Hence, one of the main arguments for GVL free threading, which is to reduce memory usage, is much less important in the case of Ruby.

Python is at a major disadvantage over Ruby in terms of Copy-on-Write effectiveness,

そうなのか

dak2dak2

参照カウント(Reference counting)

参照カウント(Reference counting)はオブジェクト(動的に確保したメモリ領域の単位)が参照されている数を元に領域の開放を行うGCである。全てのオブジェクトに対して参照カウントと呼ばれる整数値を付加する。参照カウントはプログラム全体にこのオブジェクトを指し示すポインタがいくつ存在するかを記録するもので、変数の代入などによって変化する。そして、この参照カウントが0になった場合、つまり、どこからも参照されなくなった場合にそのオブジェクトを開放するというものである。

オブジェクトが参照カウントを持っていて、参照されたら + されなくなったら - して 0 になったらオブジェクトを破棄

循環参照が起こったら回収できない

dak2dak2

Tracing collectors

  1. 初期化(例、マークをはずす)
  2. トレース(例、マークもしくはコピー)
  3. 回収
  4. 確保した領域の再利用

Tracing collectorsは名前のとおりオブジェクトに含まれるポインタをトレースして利用しているオブジェクト、していないオブジェクトを判別する。このとき、トレースのはじめとなるオブジェクトをルート(Root)と呼び、到達できたオブジェクトはプログラムが利用中のオブジェクト、到達できなかったオブジェクトはごみとみなし回収を行う。したがって、アルゴリズムの回収段階では確保した全てのオブジェクトに何らかの方法でアクセスできる必要がある。
トレースのアルゴリズムにはマークアンドスイープ(Mark and Sweep)、コピー(Copying)という二つの方法が存在する。

Root から到達できたのは残して、それ以外は破棄

Mark and Sweep

マークアンドスイープはトレース段階と回収段階が明確に分かれたアルゴリズムである。

トレース段階ではルートから到達できるオブジェクトに印をつけていく。
回収段階では、全てのオブジェクトを調べマークのついていないオブジェクトを回収する。
循環参照が起こったオブジェクトでも回収が行える

dak2dak2

GPT に聞いてみる

Q: Reference counting GC と Mark and Sweep GC では fork(2) でプロセスを生成する環境においてどちらにパフォーマンス優位性がありますか?

✅ fork(2) 環境における「参照カウント(Reference Counting)GC」と「マーク & スイープ(Mark and Sweep)GC」のパフォーマンス比較
結論:
💡 fork(2) によるプロセス分岐を多用する環境では、「マーク & スイープ GC」の方がパフォーマンスに優位性がある。
💡 理由は、マーク & スイープ GC は「オブジェクトへの頻繁な書き込みを避け、Copy-on-Write(CoW)を維持しやすい」ため。
💡 一方、「参照カウント GC」は refcnt++ による頻繁な書き込みが発生し、CoW を破壊しやすいため、メモリ効率が悪化する。

参照されると参照カウントが増えて書き込みが発生するのか
確かに CoW の恩恵があまり受けられなそう

Mark and Sweep だと参照されただけで書き込みにならないのと、GC が実行のタイミングでオブジェクトの取捨選択が行われるから Reference counting GC と比べて CoW の恩恵を受けやすいのか

dak2dak2

Copy on writes are a big problem in large Python application that rely on multiple processes sharing the same memory.
With the implementation of gc.freeze, we've attenuated the problem by removing the CoW coming from the GC Head. However, reference counting still causes CoW.

Python の issue に書いてた

https://bugs.python.org/issue40255

dak2dak2

ML 系の処理だとデータ量自体が大きいしオブジェクトの数も膨大そうなので CoW の恩恵は受けにくそうだなという印象を持った

dak2dak2

文章の背景がなんとなく分かったので Close

このスクラップは3ヶ月前にクローズされました