Exploring Minitest Concurrency

Written: Oct 4, 2014

It’s not old, it’s vintage.

This post was last updated some years ago and hasn’t been updated recently. Be aware that some of the content, tools, and techniques described may not be completely up-to-date.

Among all the other morsels of sunshine and goodness it offers, Minitest gives you the ability to denote that some or all of your test cases are able to run concurrently, and as pointed out in the source comments, that can only mean one thing: that you rule and your tests are awesome.

In doing so, you're admitting that you rule and your tests are awesome

I wanted to better understand how and when to use parallelization in my own code, so I dove into the source and ran some simple bench tests to come up with some guidelines for how to put it to work.

The basics

There are two ways to switch on parallelization in your tests. You can apply it to all test cases in your suite by adding the following lines to your test_helper.rb file:

1
2
3
4
5
6
7
# test/test_helper.rb

require 'minitest/hell'

class Minitest::Test
  parallelize_me!
end

The inclusion of Minitest::Hell ensures that the tests in all cases will be executed in random order by overriding Minitest::Test.test_order to return :parallel. (Random test ordering also happens to be the default behavior, but this can be overridden per-test case.) The call to parallelize_me! causes all test cases to queue tests across a pool of concurrent worker threads via the Minitest::Parallel::Executor helper class. The implementation is worth checking out because it’s super simple and does its job nicely - a really great example of restrained, focus code.

It’s also perfectly reasonable, though presumably somewhat less awesome, to call parallelize_me! only on certain individual test cases when some tests require execution in a strict order. It’s also possible to override global parallelization settings on individual test cases to require serial execution by calling i_suck_and_my_tests_are_order_dependent! within the test class.

One additional feature that’s not documented anywhere that I could find except in the source code: you can specify the number of worker threads started and run by Minitest::Parallel::Executor by providing an environment variable on the command line when you run your tests like so: N=4 rake test. (Minitest defaults to N=2 if no other value is given.)

Ruby concurrency vs. Ruby parallelism

There’s an important difference between these two concepts that needs to be made clear before moving forward: concurrency indicates an ability to manage several tasks, while parallelism implies that I can actually do multiple things simultaneously. (Ilya Grigorik summed this up better than I ever could in a talk he gave on the subject.) As an example, I might be able to juggle lots of chainsaws (excellent concurrency), but I probably can’t hold more than one of them at any given time (no parallelism).

Juggling chainsaws

In Ruby, whether you achieve parallelism or only concurrency depends on the thread model implemented by the interpreter. This is a decision that you the developer make. Minitest can’t influence it either way. Concretely, MRI’s thread model maps Ruby Thread objects onto system-level threads, but the Global Interpreter Lock (GIL) ensures thread safety by ensuring that only one is ever allowed to execute at any given time, so parallelism within a single Ruby process is impossible. Other Ruby implementations - JRuby and Rubinius, as examples - don’t have the GIL, and so true parallel execution is possible in both these environments.

Ryan Davis, the author of Minitest, makes no secret about what Minitest::Parallel was written for:

I don’t care in the slightest about trying to make your tests run faster. You deserve your pain if you write slow tests. What I do care about greatly is making your tests hurt and this will do that.

- Ryan Davis

And he’s right, of course. Randomizing the order in which tests are executed gives you a certain measure of confidence that you’ve managed to write independent tests, and that’s the default behavior Minitest provides. But by then executing tests across multiple threads that don’t share state between, you’re adding an additional level of isolation insurance. But why shouldn’t we have all of that and the fastest possible test execution? Are there any trade-offs?

The setup

I set up a base project that consisted of an empty Ruby gem with a basic Rakefile and code to generate 1000 tests spread over ten more or less identical test cases which all followed the same pattern:

1
2
3
4
5
6
7
8
9
10
11
12
# test/one_fake_test.rb

require "test_helper"

class OneFakeTest < Minitest::Test
  (1..100).each do |i|
    define_method("test_#{ i }") do
      (1..8000).reduce(:*)
      assert(true)
    end
  end
end

My goal was to demonstrate test suite performance for three options:

  • Baseline - serial execution only
  • Minimally parallel - concurrent with two workers
  • Maximally parallel - concurrent with eight workers (= number of CPU threads on my development machine)

In order to see how Minitest behaved across different interpreters, I ran the same test using MRI Ruby 2.1.1, JRuby 1.7.9, and Rubinius 2.2.10.

For each implementation and each concurrency level, I executed three test runs and calculated the averages values for suite execution and number of assertions per second as reported by Minitest as well as the total and CPU times reported by the Unix time command. The tables below show the averages along with the relative differences over the baseline (serial) case in parentheses.

Round 1: MRI

MRI Ruby 2.1.1
Total Time
(MT)
Assertions/s
(MT)
Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
23.42s 42.71 24.06s 23.81s
Parallel
(2 threads)
26.99s
(15.28%)
37.07
(-13.20%)
27.65s
(14.95%)
27.39s
(15.02%)
Parallel
(8 threads)
29.99s
(28.09%)
33.34
(-21.93%)
30.66s
(27.45%)
30.18s
(26.74%)

The GIL optimizes for single-threaded execution, so while the additional concurrency might be giving us better assurances that each test is isolated and independent, it’s not free. It’s not too painful in this test application, but if I were running a suite for a large, well-covered Rails application where some slower tests are usually unavoidable, I might feel like the added insurance is too expensive.

Round 2: JRuby

JRuby 1.7.x
Total Time
(MT)
Assertions/s
(MT)
Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
66.18s 15.11 72.12s 81.96s
Parallel
(2 threads)
40.35s
(-39.04%)
26.83
(77.53%)
46.45s
(-35.59%)
95.75s
(16.82%)
Parallel
(8 threads)
17.28s
(-73.90%)
58.02
(283.96%)
23.24s
(-67.77%)
134.28s
(63.83%)

My expectations for JRuby were higher because it uses a threading model that allows for fully parallel execution in multicore environments, and I was not disappointed. Despite being kind of a slow starter, JRuby was able utilize a lot more available CPU when I increased the concurrency, and I was able to run the test suite in about one-third the time.

Round 3: Rubinius

Rubinius 2.2.x
Total Time
(MT)
Assertions/s
(MT)
Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
24.31s 41.15 27.22s 28.80s
Parallel
(2 threads)
16.51s
(-32.07%)
60.61
(47.30%)
19.25s
(-29.29%)
35.14s
(22.02%)
Parallel
(8 threads)
12.81
(-47.31%)
78.10
(89.80%)
15.62s
(-42.62%)
63.42s
(120.18%)

Rubinius was the quickest interpreter I tested by a mile. Even though the performance gains from each additional thread weren’t as substantial as I saw in JRuby and tapered off more quickly, the fact is that Rubinius was really nimble, did a good job utilizing multiple cores, and needed about half the startup time of JRuby. I haven’t used Rubinius before, but this experience has convinced me to take a closer look at it in the future.

The long and short of it

In the end, I learned as much about Ruby and Ruby implementations as I did about Minitest, and so even though this all took some time to think through and set up, it was time well spent.

  • Concurrent execution in Minitest isn’t just (or even primarily) about speed. Even with no improvement in performance, the increased test isolation is a worthwhile benefit.
  • When using a Ruby implementation that allows for true parallel execution, enable parallel execution with concurrency equal to or approaching the number of available CPU cores on your test system.
  • When using MRI Ruby, you’ll see the best performance when running in a single thread, but taking a slight performance hit to run concurrently in two threads would probably be a good compromise.

Your mileage may vary when it comes to implementing (or not) concurrent execution within your own real-world tests, but hopefully this will give you a better understanding of how it works in Minitest, what you gain from it, and how to make it work for you.

Additional resources