🧵

RubyのParallel gemで並列処理を理解する

2024/12/15に公開

本記事はAkatsuki Games Advent Calendarの15日目の記事です。

処理を高速化させる手段のひとつとして、並列処理が挙げられます。
Rubyにおいて、この並列処理を実現させるgemとしてParallel gemというものがあります。

Parallel gemとは

https://github.com/grosser/parallel

Run any code in parallel Processes(> use all CPUs), Threads(> speedup blocking operations), or Ractors(> use all CPUs).
Best suited for map-reduce or e.g. parallel downloads/uploads.

Parallel gem を使うことで、Rubyにおける3種類の並列化(マルチプロセス、マルチスレッド、Ractor)を手軽に実装することができ、ループ処理を並列化して処理時間の短縮が可能です。

実行オプション

Parallel gemを使用する際には、以下のオプションで並列処理の方式を選択できます。

プロセス(in_processes)

# 3 Processes -> finished after 1 run
results = Parallel.map(['a','b','c'], in_processes: 3) { |one_letter| SomeClass.expensive_calculation(one_letter) }

各タスクが別々のプロセスで実行されます。
各プロセスは独立したメモリ空間を持つため、変数やデータの共有が難しくなります。

スレッド(in_threads)

# 3 Threads -> finished after 1 run
results = Parallel.map(['a','b','c'], in_threads: 3) { |one_letter| SomeClass.expensive_calculation(one_letter) }

各タスクが同一プロセス内の異なるスレッドで実行されます。
同一プロセス内のスレッドはメモリを共有するため、タスク間でのデータのやり取りが可能ですが、データの競合に注意する必要があります。

Ractor (in_ractors)

# 3 Ractors -> finished after 1 run
results = Parallel.map(['a','b','c'], in_ractors: 3, ractor: [SomeClass, :expensive_calculation])

RactorはRuby3.0から導入されたRuby独自の並列・並行処理のための機能です。
スレッドがオブジェクトをすべて共有することに対して、Ractorは共有可能なオブジェクトが制限されているため、より簡単にスレッドセーフな処理が書けるという特徴があります。
しかしながら、Parallel gemにおいてRactorオプションには "Experimental and unstable"という記述があり、まだ実験的な機能ではあるようです。
本記事ではin_ractorsオプションについての詳しい説明は割愛するので、気になる方は調べてみてください。

計算処理の並列化

今回は計算量の多い処理の高速化として、以下のような0から99,999までの総和を計算するサンプルコードでベンチマークを取ってみます。

require 'parallel'
require 'benchmark'

def task
  100_000_000.times.reduce(:+)
end

Benchmark.bm do |x|
  x.report("serial") do
    (1..4).each { task }
  end
  x.report("processes") do
    Parallel.map(1..4, in_processes: 4) { task }
  end
  x.report("threads") do
    Parallel.map(1..4, in_threads: 4) { task }
  end
end

上から、直列処理、プロセスによる並列処理、スレッドによる並列処理の実行時間を計測していきます。

環境

  • CPU: Apple M3 Pro (12コア, 12スレッド)
  • Ruby: Ruby 3.3.5 (CRuby)
  • OS: macOS Sonoma 14.7.1
  • Gem: Parallel 1.26.3

計測結果

結果は以下の通り。

手法 User Time (s) System Time (s) Total CPU Time (s) Real Time (s)
serial 10.777846 0.006962 10.784808 10.784823
processes 0.000389 0.001830 11.430728 2.882804
threads 10.894108 0.009799 10.903907 10.901029

一番右の値が実際の経過時間にあたります。
serialとthreadsの値はほぼ変わらず、processesのみ8秒ほど速くなっているのが分かります。

今回の例では、なぜスレッドによる並列化は速くならなかったのでしょうか。

Ruby GVL

インタプリタの中で動作するプログラムが、1つのCPUコア上で1つだけ実行されることを保証するための仕組みを一般的にGIL(Global Interpreter Lock)と呼びます。
これにより、スレッドセーフでないコードを書いてもデータ競合を防ぐことができます。(個別のデータの排他ロックが不要)
この機能は、Ruby(CRuby)においてはGVL(Giant VM Lock, Global VM Lock)という名前で実装されています。

https://docs.ruby-lang.org/ja/latest/class/Thread.html

実装

ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。

今回のサンプルコードのようなCPUの計算処理に依存するCPUバウンドな処理では、このGVLによって、マルチスレッドで実行されたとしても一つの実行中のスレッド以外は待ち状態になってしまうため、直列処理と同等の結果になりました。
一方で、Threadクラスの引用には「IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します」とあるため、ディスク読み書きやネットワーク通信などの入出力操作を行うI/Oバウンドな処理の場合はスレッドに軍配が上がりそうです。

今度は、ネットワーク通信を行うサンプルコードで計測をして試してみます。

I/O処理の並列化

今回は、入出力操作を行う処理の高速化として、複数のウェブサイトの内容を取得するサンプルコードでベンチマークを取ってみます。

require 'parallel'
require 'benchmark'
require 'net/http'

urls = [
  "https://www.google.com",
  "https://www.yahoo.com",
  "https://www.bing.com",
  "https://www.amazon.com",
  "https://www.reddit.com",
  # ...
  # 30件ほどのURLの配列
]

def task(url)
  Net::HTTP.get(URI(url))
end

Benchmark.bm(10) do |x|
  x.report("serial") do
    urls.each { |url| task(url) }
  end

  x.report("processes") do
    Parallel.map(urls, in_processes: 5) { |url| task(url) }
  end

  x.report("threads") do
    Parallel.map(urls, in_threads: 5) { |url| task(url) }
  end
end


計測結果

結果は以下の通り。

手法 User Time (s) System Time (s) Total CPU Time (s) Real Time (s)
serial 0.467324 0.227928 0.695252 18.393669
processes 0.009236 0.036518 0.774733 3.671257
threads 0.361206 0.199607 0.560813 3.279803

processes、threadsともにserialからは15秒ほど速くなっています。
しかし、processesとthreadsの間にほとんど差はありませんでした。

マルチプロセスの場合、プロセスをforkするためのオーバーヘッドがあります。
そのため、処理中以外の時間がスレッドに比べ増加すると予想したのですが、思っていたのとは違う結果となりました。
これは推測ですが、よりメモリ使用量の多い処理でないと、オーバーヘッドによる差はでないのかもしれません…。

まとめ

本記事ではParallel gemを利用したサンプルコードを動かして、処理時間がどのように変化するかを見ていきました。
プロセスが得意とするCPUバウンドな処理、スレッドが得意とするI/Oバウンドな処理、どちらも試してみましたが、今回の結果に限って見ると、プロセスの方がどちらの処理時間も短縮できており、汎用性高く利用できそうと感じました。

どちらが最適かは処理の内容に加え、コア数やスレッド数などの環境も大きく影響するため、その時々の状況に応じてどちらの手法を選択するかを考えていきたいです。

Discussion