The performance of FizzBuzz in Ruby

August 28, 2019 Link to post  Permalink

I recently came across this post on Dev.to where the author implements the simple Fizz Buzz algorithm in 5 different languages, including Ruby. What caught my eye was that the implementations were slightly different between the languages, and not just because the language forced them to be. With my performance spectacles on, I wondered how the differences in implementation might affect the speed of the algorithm if implemented in the same language.

Now, Fizz Buzz is used as a simple recruiting test to filter out people who can’t program, and all of these implementations will get you past that bar, and none of them should cause you to fail the interview process. Although the case version might confuse your interviewer as it’s a really unusual use of that feature.

But it is interesting to see something that isn’t complicated implemented in different ways to see the relative speed of a technique. Even the slowest here is actually very fast, but the difference between the very fastest and slowest implementations may be useful to know when you’re building code that needs to be performant in the future.

About FizzBuzz

Fizz Buzz is a simple algorithm. Usually, you have to take the numbers between 1 and 100 and, for each, print ‘Fizz’ if the number is a multiple of 3, print ‘Buzz’ if it’s a multiple of 5, ‘FizzBuzz’ if it’s a multiple of both 3 and 5, and if none of these apply, then just print the number.

For testing, I’m skipping the printing part, and just returning a string for each value instead.

Building a String

The original article used this method, as it works quite well with Ruby. This has the advantage of not needing to test for being a multiple of 15, as the addition of the Fizz and Buzz in the correct order handles the FizzBuzz case.

# building a string
def fizz_buzz_text(num)
  result = ''
  result += 'Fizz' if (num % 3).zero?
  result += 'Buzz' if (num % 5).zero?
  result.empty? ? num.to_s : result
end

Build an Array and Join to return

One common pattern in Ruby code when building a final string is to not build the string at all, but to add all the pieces to an array and then use the array’s join method to create a single string at the end. This can often be more efficient, as it reduces the number of memory allocations needed to get the final output. For a maximum of two values, and often only one, the Array and Join technique isn’t the most efficient.

Here’s my implementation

# building an array
def fizz_buzz_array(num)
  result = []
  result << 'Fizz' if (num % 3).zero?
  result << 'Buzz' if (num % 5).zero?
  result.empty? ? num.to_s : result.join
end

If/Else

One of the other language examples in the original used a simple if/elsif cascade to check for each possible return and then returned the correct value, with the else case handling the numeric output. This works well in Ruby too, with only 4 possible choices. If there were more possibilities, this technique can become a little cumbersome.

# using if/else to return a string
def fizz_buzz_if_else(num)
  if (num % 15) == 0
    "FizzBuzz"
  elsif (num % 5) == 0
    "Fizz"
  elsif (num % 3) == 0
    "Buzz"
  else
    num.to_s
  end
end

If with early return

The next technique is to use what I call the early return where you check for specific values at the top of the function. Usually, this technique is used to detect out of range values, like nil for parameters, before getting into the main work. But here, we’re using the early return as part of the main algorithm. That works here, but it’s probably not a great thing to use a ‘handle error cases’ technique for providing the real work, mainly because your co-workers will not expect this and could easily ignore the guard clauses as not being the meat of the code.

def fizz_buzz_if_return(num)
  return "FizzBuzz" if (num % 15) == 0
  return "Fizz" if (num % 5) == 0
  return "Buzz" if (num % 3) == 0

  num.to_s
end

If/Else with frozen strings

A few years ago, Ruby added the ability to ‘freeze’ data, and more recently, allowed developers to freeze all strings in a Ruby file with a pragma comment at the top of the file. This freezing allows the compiler to keep the strings out of the Garbage Collection system, and reduces run-time load. It also allows strings with the same content to be shared, but this feature isn’t used below. Note that, since I’m putting all this code into one file, I have to manually freeze each string to distinguish this code from the previous cases.

def fizz_buzz_if_frozen(num)
  if (num % 15) == 0
    "FizzBuzz".freeze
  elsif (num % 5) == 0
    "Fizz".freeze
  elsif (num % 3) == 0
    "Buzz".freeze
  else
    num.to_s
  end
end

If/Else with frozen strings and .zero?

I added this one to show the difference between == 0 and calling the .zero? method to check the modulus return. Often, using a method like .zero? can help with readability, but it’s always a good idea to think about how that affects the performance of your code. Often, it’s worth the cost. Sometimes, it isn’t.

def fizz_buzz_if_frozen_zero(num)
  if (num % 15).zero?
    "FizzBuzz".freeze
  elsif (num % 5).zero?
    "Fizz".freeze
  elsif (num % 3).zero?
    "Buzz".freeze
  else
    num.to_s
  end
end

Case with custom comparison methods

This is the weirdest version of the algorithm. Here, we’re using the mechanism that the case statement uses for comparison - the === operator – to call the Proc code we’ve created for each of the comparisons we want to make.

With the weirdness of the operations, and the fact this is isn’t really much clearer than the if/else cases, this isn’t a great implementation unless you want to win prizes for obscure Ruby code.

Divisable_by_fifteen = Proc.new { |i| i % 15 == 0 }
Divisable_by_three = Proc.new { |i| i % 3 == 0 }
Divisable_by_five = Proc.new { |i| i % 5 == 0 }

def fizz_buzz_case(num)
  case num
  when Divisable_by_fifteen
    "FizzBuzz"
  when Divisable_by_five
    "Fizz"
  when Divisable_by_three
    "Buzz"
  else
    num.to_s
  end
end

Cache results and lookup in array

Now, for a single run of 1 to 100, caching the results isn’t very useful. But for the benchmarking code we’re about to run, the algorithm will be executed many times, so caching the results can be significantly faster. Here we use one of the other implementations above to pre-populate the cache, and then, for each number from 1 to 100, we just index into the pre-populated array of results.

When you can use this technique in real code, it can pay off quite well.

VALUES = []

def create_cache(max = 100)
  1.upto(max).each { |i| VALUES[i] = fizz_buzz_if_frozen(i) }
end

def fizz_buzz_cached(num)
  VALUES[num]
end

Benchmarking all the above versions

So, let’s run the benchmark code - here’s the gist of the FizzBuzz benchmark

Download and run it with:

gem install benchmark-ips
ruby ./fizzbuzz.rb

Results:

Calculating -------------------------------------
                case     43.549k (± 3.6%) i/s -    221.442k in   5.093620s
           if - else     84.462k (± 3.7%) i/s -    428.145k in   5.077886s
         if - return     84.648k (± 2.8%) i/s -    426.360k in   5.041176s
         if - frozen     92.298k (± 0.9%) i/s -    463.892k in   5.026430s
  if - frozen - zero     81.344k (± 3.7%) i/s -    410.958k in   5.060937s
                text     58.631k (± 0.8%) i/s -    296.769k in   5.061982s
               array     45.719k (± 1.2%) i/s -    233.121k in   5.099686s
              cached    274.309k (± 2.0%) i/s -      1.391M in   5.072926s

Comparison:
              cached:   274308.7 i/s
         if - frozen:    92298.4 i/s - 2.97x  slower
         if - return:    84648.0 i/s - 3.24x  slower
           if - else:    84462.3 i/s - 3.25x  slower
  if - frozen - zero:    81344.4 i/s - 3.37x  slower
                text:    58631.1 i/s - 4.68x  slower
               array:    45719.3 i/s - 6.00x  slower
                case:    43548.5 i/s - 6.30x  slower

Note: Your absolute performance numbers are probably different. The key information is the relative difference in performance, which is what the benchmark/ips gem does best.

So the cached version is the fastest, by almost 3 times than the next fastest version. Since we could run it 1.4M times, the caching paid off! This isn’t really a useful result for the interview question, but gives us a useful ‘fastest possible’ result to compare against.

The most interesting results are the next 3 - the early return and if/else are almost the same speed. So, pick whichever technique works for the code you’re writing at the time. But the frozen if/else is faster than both by a significant margin. If you can get this performance benefit with a simple 1 line addition to the top of each Ruby source file, is there any reason not to do that?

The next result is the difference between using == 0 and .zero?. I’m old school, so the zero? method seems like a little too much for me. And it turns out to be a bit slower, so I can convince myself to not use that method.

Surprisingly, the original text building version of the method is quite far down the list with performance, but you can still run the 1 to 100 algorithm almost 60K times per second! The array version is slower still. As mentioned previously, building arrays works best when you have many strings to build into one result, and is less useful when most of your cases have only one string in the array.

The case version is also the slowest, but with the amount of extra work being done here to make the comparison, it holds up pretty well with the array version. Comparing integers isn’t really what the Ruby case statement is for, it’s a vary powerful statement when used for what it does well.

Summary

Although FizzBuzz is a fairly simple thing to create, even the simplest things can be implemented in different ways, and that can have significant impact in both performance and ease of usage. I’ve created 8 different versions, and could easily do more with different combinations of the .zero? method and frozen strings.