Parallelizing compute with the parallel gem
As we discussed in the posts related to Thread.new for concurrency, Ruby does not provide parallelism out of the box with threads alone. Due to the Global Interpreter Lock, a single Ruby process using threads for concurrency will only ever saturate a single CPU core.
Therefore, the go-to solution for parallelism in Ruby is to use multiple processes. In the post on forking, we could see how a Ruby web server might use fork to create multiple worker processes to handle incoming requests in parallel.
However, since manually managing inter-process communication can be tedious, the parallel gem can be a great help. It provides a simple interface for running code in parallel across multiple processes:
require "benchmark"
require "parallel"
WIDTH = 1_200
HEIGHT = 800
MAX_ITERATIONS = 1_000
def mandelbrot_iterations(x, y)
real = -2.0 + x * 3.0 / WIDTH
imaginary = -1.2 + y * 2.4 / HEIGHT
zr = 0.0
zi = 0.0
iterations = 0
while iterations < MAX_ITERATIONS && zr * zr + zi * zi <= 4.0
zr, zi = zr * zr - zi * zi + real, 2.0 * zr * zi + imaginary
iterations += 1
end
iterations
end
Benchmark.bm do |bm|
bm.report("non-parallel") do
row_totals = (0...HEIGHT).map do |y|
(0...WIDTH).sum { |x| mandelbrot_iterations(x, y) }
end
puts "Mandelbrot escape iterations: #{row_totals.sum}"
# => Mandelbrot escape iterations: 206825643
end
bm.report("parallel") do
row_totals = Parallel.map(0...HEIGHT) do |y|
(0...WIDTH).sum { |x| mandelbrot_iterations(x, y) }
end
puts "Mandelbrot escape iterations: #{row_totals.sum}"
# => Mandelbrot escape iterations: 206825643
end
end
# Output will vary by machine. Here's what I got on my Apple M1 Pro:
# user system total real
# non-parallel 22.118777 0.090735 22.209512 ( 22.262529)
# parallel 0.017934 0.029973 25.630064 ( 3.002575)
Hardly anything changed in the code, we just replaced map with Parallel.map. But we calculated the results more than 7x faster!
Note that, by default, Parallel.map will use as many processes as there are CPU cores. You can customize this with the in_processes option, e.g. Parallel.map(..., in_processes: 4).
If you're running a recent version of Ruby, you may also want to try out the in_ractors option, which uses Ractors instead of processes for parallelism. But that's a topic for another day.
History
Michael Grosser first published the parallel gem to RubyGems in 2009. Its enumerable-like API and fork-based implementation have remained mostly unchanged since then.
The newer in_ractors option follows Ruby's introduction of Ractor in Ruby 3.0, released in December 2020. The parallel gem added Ractor support in version 1.22.0, released in March 2022.