Lazy Enumerators are Faster...or are they?

Apologies for the clickbaity title here, but I was genuinely surprised by the performance from my own benchmarks and slapped the "...or are they?" onto my draft and here we are.

Lazy numerators work very similar to normal Ruby Enumberators but they don't execute until immediately. Instead they evaluate values on an as-needed basis. For a deep dive into how to use it, check the official documentation.

Thematically I like the idea of lazy evaluating enumerators quite a lot. It lines up well with ActiveRecord's lazy evaluation.

A basic usage example:

# Standard enumerator
(0..15).select { |i| i.even? }.map { |i| puts i; i *= 2 }.first(3)
0
2
4
6
8
10
12
14
=> [0, 4, 8]

# Lazy enumerator.  Notice how we only manipulate our data until we have the first three qualifying elements.
(0..15).lazy.select { |i| i.even? }.map { |i| puts i; i *= 2 }.first(3)
0
2
4
=> [0, 4, 8]

Benchmarking

Can we mimic the lazy behavior without using the lazy operator? Let's find out by attempting to find the first one hundred perfect squares between two large numbers. I'll wrap our code in some benchmarking to see how each performs.

iterations = 500

Benchmark.measure do
    iterations.times do
        # The "I don't know about lazy" way
        (100000..50000000).each_with_object([]) do |i, array|
            array << i if Math.sqrt(i) % 1 == 0

            # We are too lazy to process beyond our desired count
            break array if array.length == 100
        end
    end
end
# This took 5.598 seconds

Benchmark.measure do
    iterations.times do
        # The lazy way
        (100000..50000000).lazy.select { |i| Math.sqrt(i) % 1 == 0 }.first(100)
    end
end
# This took 8.290 seconds

Perhaps we're picking an unfair example, but 500 iterations of our lazy implementation is slower than a traditional implementation! Certainly we're doing better than a non-lazy implementation that doesn't break out of its loop, but the results are still somewhat surprising. A shame given how much nicer the lazy code is too.

Yusuke Endoh addressed this directly when lazy's performance issues were reported as a bug.

Enumerator::Lazy is not a silver bullet; it removes the overhead for creating an intermediate array, but brings the drawback for calling a block. Unfortunately, the latter is much bigger than the former. Thus, in general, Lazy does bring performance drawback.

When should we use the lazy enumerator?

The code cleanliness may be worth the small amount of additional overhead.

The proportional overhead of using the lazy enumerator decreases for slower chained operations. Consider the case where you're iterating over a list of items and making an HTTP GET for each until you find the object(s) that you're looking for. The overhead of those HTTP calls will far outweigh the performance hit from using a lazy enumerator.