Stream is cool, but...

Mon, Jul 26, 2021 5 min read
Use Stream wisely. For some use cases when you have large lists it can be great, but other times it is not the best choice.

When you come to Elixir with big object-oriented baggage, you try to map some of your solutions to what you previously used. Let’s try to build a list on even numbers, for instance using javascript:

let even = [];
for (let i = 0; i < 2000; i += 2) {
  even.push(i);
}

Now let’s try to do the same with Elixir. The first attempt, since I used for in javascript, let’s use a list comprehension:

for n <- 0..2000, rem(n, 2) == 0, do: n

Seems kind of dumb to have to do the filter.

Wait, we have that shinny Stream that allows you to build infinite lists.

Stream is very cool. Due to their laziness, streams are helpful when working with large (or even infinite) lists.

some_big_list
|> Stream.map(...)
|> Stream.map(...)
|> Enum.sum()

For pipelines where you do lots of transformations, it will for sure be helpful. Let’s use it here.

Stream.iterate(0, &(&1 + 2))
|> Stream.take_while(&(&1 <= 2000))
|> Enum.to_list()

Easy peasy, lemon squeezy. Now let’s try it with a really big list:

But at what cost?

Stream.iterate(0, &(&1 + 2))
|> Stream.take_while(&(&1 <= 2000000))
|> Enum.to_list()

Weird, it seemed a bit slow. So let’s make it even more significant.

Stream.iterate(0, &(&1 + 2))
|> Stream.take_while(&(&1 <= 20000000))
|> Enum.to_list()

For our use case, it looks slow, really slow. How slow, you might ask. So instead of following gut feelings, let’s benchmark it.

$ mix new big_list

Let’s add Benchee to mix.exs. And then build the even list in different ways to get a comparison of speed.

First, let’s use our comprehension:

def using_comprehension(number), do: for(n <- 0..number, rem(n, 2) == 0, do: n)

Now let’s try using Enum. Should be similar in speed 🤔:

def using_enum(number), do: 0..number |> Enum.filter(&(rem(&1, 2) == 0))

Let’s try to do it better by using a range:

 def using_range(number), do: 0..number//2 |> Enum.to_list()

Next using our faithful recursion:

  def using_recursion(number), do: using_recursion(number, 0)

  def using_recursion(number, n) when n <= number,
    do: [n | using_recursion(number, n + 2)]

  def using_recursion(number, n) when n > number, do: []

And check with tail call optimization:

def using_tail_recursion(number), do: using_tail_recursion(number, 0, [])

def using_tail_recursion(number, n, even_numbers) when n <= number,
  do: using_tail_recursion(number, n + 2, [n | even_numbers])

def using_tail_recursion(number, n, even_numbers) when n > number,
  do: Enum.reverse(even_numbers)

And finally, with our stream:

  def using_stream(number) do
    Stream.iterate(0, &(&1 + 2)) |> Stream.take_while(&(&1 <= number))
  end

So let’s benchmark it:

def benchmark(n \\ 200) do
  Benchee.run(%{
    "using_comprehension" => fn -> using_comprehension(n) end,
    "using_range" => fn -> using_range(n) end,
    "using_enum" => fn -> using_enum(n) end,
    "using_recursion" => fn -> using_recursion(n) end,
    "using_tail_recursion" => fn -> using_tail_recursion(n) end,
    "using_stream" => fn -> using_stream(n) |> Enum.to_list() end
  })
end

So the results are …

iex(1)> BigList.benchmark(200)
...
Comparison:
using_recursion          2002.69 K
using_tail_recursion     1634.62 K - 1.23x slower +0.112 μs
using_range                713.44 K - 2.81x slower +0.90 μs
using_comprehension        277.10 K - 7.23x slower +3.11 μs
using_enum                 192.23 K - 10.42x slower +4.70 μs
using_stream              153.70 K - 13.03x slower +6.01 μs

Our recursion function is by far the fastest, followed by its tail optimization version 😏. The range version is pretty quick and a big surprise. The comprehension is faster than the enum version. Since they kind of do the same, I expected them to be similar, but it’s quite a difference. And then down at the bottom, our slow stream 😿.

Let’s try with a bigger list to see if it changes our benchmark.

iex(1)> BigList.benchmark(2000)
...
Comparison:
using_recursion           248.05 K
using_tail_recursion      188.93 K - 1.31x slower +1.26 μs
using_range                 76.89 K - 3.23x slower +8.97 μs
using_comprehension         27.33 K - 9.08x slower +32.56 μs
using_enum                  20.69 K - 11.99x slower +44.30 μs
using_stream               15.20 K - 16.32x slower +61.77 μs

Nothing changed all that much. In comparison, the results are a lot worse compared to the recursion version. Now let’s try with a huge list.

iex(1)> BigList.benchmark(2000000)
...
Comparison:
using_tail_recursion         66.02
using_range                   43.65 - 1.51x slower +7.76 ms
using_recursion              36.93 - 1.79x slower +11.93 ms
using_comprehension           22.74 - 2.90x slower +28.82 ms
using_enum                    17.42 - 3.79x slower +42.27 ms
using_stream                  9.97 - 6.62x slower +85.11 ms

Tail recursion optimization starts to optimize 😏. And all other versions are not as bad compared to the previous one. Stream seems to be slow as hell compared to all other versions, regardless of the list size.

Now let’s change the use case. We will sum all the even numbers that have the lucky number 7 on them.

  defp lucky_n7?(n), do: n |> Integer.digits() |> Enum.member?(7)
  def benchmark(n \\ 200) do
    Benchee.run(%{
      "using_range" => fn -> using_range(n) |> Enum.filter(&lucky_n7?/1) |> Enum.sum() end,
      "using_comprehension" => fn -> using_comprehension(n) |> Enum.filter(&lucky_n7?/1) |> Enum.sum() end,
      "using_enum" => fn -> using_enum(n) |> Enum.filter(&lucky_n7?/1) |> Enum.sum() end,
      "using_recursion" => fn -> using_recursion(n) |> Enum.filter(&lucky_n7?/1) |> Enum.sum() end,
      "using_tail_recursion" => fn -> using_tail_recursion(n) |> Enum.filter(&lucky_n7?/1) |> Enum.sum() end,
      "using_stream" => fn -> using_stream(n) |> Stream.filter(&lucky_n7?/1) |> Enum.sum() end
    })
  end

Let’s start with a small list first.

iex(1)> BigList.benchmark(200)
...
Comparison:
using_recursion           199.61 K
using_tail_recursion      195.24 K - 1.02x slower +0.112 μs
using_range                168.20 K - 1.19x slower +0.94 μs
using_comprehension        123.23 K - 1.62x slower +3.11 μs
using_stream              105.70 K - 1.89x slower +4.45 μs
using_enum                 103.47 K - 1.93x slower +4.66 μs

We end up with similar results as before. Increasing the list a bit:

iex(1)> BigList.benchmark(2000)
...
Comparison:
using_recursion            16.03 K
using_tail_recursion       14.81 K - 1.08x slower +5.13 μs
using_range                 13.39 K - 1.20x slower +12.26 μs
using_comprehension         10.20 K - 1.57x slower +35.62 μs
using_enum                   8.88 K - 1.81x slower +50.26 μs
using_stream                8.37 K - 1.92x slower +57.13 μs

Even though the order is the same, there isn’t such a massive difference between versions as it did in the benchmark for even numbers. But now, let’s go to the humongous list.

iex(1)> BigList.benchmark(2000000)
...
Comparison:
using_stream                  6.49
using_tail_recursion          4.29 - 1.51x slower +78.98 ms
using_recursion               3.56 - 1.82x slower +126.46 ms
using_comprehension            3.13 - 2.07x slower +165.63 ms
using_enum                     3.04 - 2.13x slower +174.33 ms
using_range                    2.90 - 2.24x slower +191.28 ms

Stream starts to shine. Not being eager and processing results, one at a time, makes a huge difference with large lists and more complicated pipelines.

When I started doing this little investigation, I wasn’t expecting some findings. First, Enum with filter is a lot slower than a comprehension with a filter. I was expecting it to be very similar. Need to find out why.

Streams are better suited for larger pipelines and aren’t the best tool for many use cases. Like everything in engineering, it’s a matter of choosing the most cost-effective, fastest, and elegant solution.

And at the end… You need to choose wisely.