Tano
Tano

Reputation: 1377

Elixir Enum.map vs For comprehension

I have a map and I am modifying each element on it, I am confused which approach is better(faster) to do it with Enum.map and then Enum.into(%{}) or to use for comprehension like

for {key, value} <- my_map, into: %{} do
  {key, new_value}
end

Upvotes: 3

Views: 2104

Answers (2)

sbacarob
sbacarob

Reputation: 2212

Original answer

You can use Benchee to run this kind of comparisons.

A simple Benchee test will show that Enum is faster for cases like this one.

iex(1)> m = %{a: 1, b: 2, c: 3, d: 4}
%{a: 1, b: 2, c: 3, d: 4}
iex(2)> with_enum = fn -> Enum.map(m, fn {k, v} -> {k, v * v} end) end
#Function<20.127694169/0 in :erl_eval.expr/5>
iex(3)> with_for = fn -> for {k, v} <- m, into: %{}, do: {k, v * v} end
#Function<20.127694169/0 in :erl_eval.expr/5>
iex(4)> Benchee.run(%{
...(4)>   "with_enum" => fn -> with_enum.() end,
...(4)>   "with_for" => fn -> with_for.() end
...(4)> })
Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-6500U CPU @ 2.50GHz
Number of Available Cores: 4
Available memory: 7.71 GB
Elixir 1.7.4
Erlang 21.0

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s


Benchmarking with_enum...
Benchmarking with_for...

Name                ips        average  deviation         median         99th %
with_enum       28.27 K       35.37 μs    ±16.16%       34.37 μs       55.21 μs
with_for        19.55 K       51.14 μs     ±9.16%       50.08 μs       59.94 μs

Comparison: 
with_enum       28.27 K
with_for        19.55 K - 1.45x slower

In general, for isn't the best option for these cases in Elixir, it's best suited for list comprehensions, which it can do pretty fast and with an easy to read syntax.

Enum's functions are optimized to handle these scenarios which are more iterative, like what you would do with a for construct in other programming languages.


Edit

Even though the main intention of my original answer was to point to a framework that helps to run such kind of comparisons so that OP could try for themselves, as pointed out by another user, the example function using Enum.map wasn't producing the same result as the one with for. As pointed out by himself, adding Enum.into to the Enum.map call results in a sometimes longer running function. So here's an update, adding some more options that could have also been considered to produce the same result, with a benchmark.

iex> m = %{a: 1, b: 2, c: 3, d: 4}
%{a: 1, b: 2, c: 3, d: 4}
iex> with_enum_map_into = fn -> m |> Enum.map(fn {k, v} -> {k, v * v} end) |> Enum.into(%{}) end
#Function<...>
iex> with_enum_map_map_new = fn -> m |> Enum.map(fn {k, v} -> {k, v * v} end) |> Map.new() end
#Function<...>
iex> with_map_new = fn -> Map.new(m, fn {k, v} -> {k, v * v} end) end
#Function<...>
iex> with_reduce_map_put = fn -> Enum.reduce(m, %{}, fn {k, v}, acc -> Map.put(acc, k, v * v) end) end
#Function<...>
iex> with_reduce_map_merge = fn -> Enum.reduce(m, %{}, fn {k, v}, acc -> Map.merge(acc, %{k => v * v}) end) end
#Function<...>
iex> with_for = fn -> for {k, v} <- m, into: %{}, do: {k, v * v} end
#Function<20.127694169/0 in :erl_eval.expr/5>
iex> Benchee.run(%{                                               
...>   "with_for" => fn -> with_for.() end,                       
...>   "with_enum_map_into" => fn -> with_enum_map_into.() end,   
...>   "with_enum_map_map_new" => fn -> with_enum_map_map_new.() end,
...>   "with_map_new" => fn -> with_map_new.() end,                  
...>   "with_reduce_map_put" => fn -> with_reduce_map_put.() end,    
...>   "with_reduce_map_merge" => fn -> with_reduce_map_merge.() end 
...> })
Benchmarking with_enum_map_into...
Benchmarking with_enum_map_map_new...
Benchmarking with_for...
Benchmarking with_map_new...
Benchmarking with_reduce_map_merge...
Benchmarking with_reduce_map_put...

Name                            ips        average  deviation         median         99th %
with_enum_map_map_new       96.55 K       10.36 μs   ±158.95%        9.08 μs       37.43 μs
with_map_new                89.98 K       11.11 μs   ±154.88%        8.94 μs       41.93 μs
with_enum_map_into          87.50 K       11.43 μs   ±168.60%        9.46 μs       30.92 μs
with_reduce_map_put         84.31 K       11.86 μs    ±63.69%       10.38 μs       38.56 μs
with_reduce_map_merge       84.29 K       11.86 μs    ±91.14%       10.25 μs       38.49 μs
with_for                    61.08 K       16.37 μs    ±95.14%       14.18 μs       36.76 μs

Comparison: 
with_enum_map_map_new       96.55 K
with_map_new                89.98 K - 1.07x slower +0.76 μs
with_enum_map_into          87.50 K - 1.10x slower +1.07 μs
with_reduce_map_put         84.31 K - 1.15x slower +1.50 μs
with_reduce_map_merge       84.29 K - 1.15x slower +1.51 μs
with_for                    61.08 K - 1.58x slower +6.01 μs

When running the benchmarks on my machine this order was consistent (hence the important of running things by yourself), with for coming in last every time and piping Enum.map into Map.new was consistently the fastest, followed by using just Map.new with a mapping function. I do insist in my original point that for in Elixir is mainly used for comprehensions, but it certainly is really nice syntactically. All of them are good options, really, and it just shows that there are several ways to achieve the same thing, which is often the case with Elixir, so, sometimes it boils down to preference and whether or not optimization is crucial to what you're doing.

Upvotes: 7

m. simon borg
m. simon borg

Reputation: 2575

@sbacarob's answer is the right idea but is missing something important and therefore incorrect. The with_enum function is missing the critical step of piping to Enum.into(%{}) to return a map, and is returning a list instead. The returns of the two functions are not equivalent rendering the benchmark comparison moot. A better test would look like this:

in "test.exs"

m = %{a: 1, b: 2, c: 3, d: 4}

with_enum = fn ->
  Enum.map(m, fn {k, v} -> {k, v * v} end) |> Enum.into(%{})
end

with_for = fn ->
  for {k, v} <- m, into: %{}, do: {k, v * v}
end

Benchee.run(%{
  "with_for" => with_for,
  "with_enum" => with_enum
})
in iex with Benchee available

iex> c "test.exs"
...
Benchmarking with_enum...
Benchmarking with_for...

Name                ips        average  deviation         median         99th %
with_for         1.82 M      548.27 ns  ±7120.96%           0 ns        1000 ns
with_enum        1.10 M      911.30 ns  ±2744.21%           0 ns        2000 ns

Comparison: 
with_for         1.82 M
with_enum        1.10 M - 1.66x slower +363.04 ns
...

Exact results vary from run to run but with_for has been consistently >1.3x faster in my tests. There's another option with Enum that I would argue is objectively better for this operation than map, both in speed and because it's purpose built for it: Enum.reduce.

in test.exs

m = %{a: 1, b: 2, c: 3, d: 4}

with_enum = fn ->
  Enum.map(m, fn {k, v} -> {k, v * v} end) |> Enum.into(%{})
end

with_for = fn ->
  for {k, v} <- m, into: %{}, do: {k, v * v}
end

with_reduce = fn ->
  Enum.reduce(m, %{}, fn {k, v}, new ->  Map.put(new, k, v) end)
end

Benchee.run(%{
  "with_for" => with_for,
  "with_enum" => with_enum,
  "with_reduce" => with_reduce
})
in iex

iex> c "test.exs"
...
Benchmarking with_enum...
Benchmarking with_for...
Benchmarking with_reduce...

Name                  ips        average  deviation         median         99th %
with_reduce        2.01 M      497.03 ns  ±7883.91%           0 ns        1000 ns
with_for           1.88 M      531.75 ns  ±6135.44%           0 ns        1000 ns
with_enum          1.07 M      933.62 ns  ±2757.77%           0 ns        3000 ns

Comparison: 
with_reduce        2.01 M
with_for           1.88 M - 1.07x slower +34.72 ns
with_enum          1.07 M - 1.88x slower +436.59 ns
...

In my tests with_reduce and with_for take turns being <1.1x slower than the other, so I'd consider it a tossup and say they're practically equivalent. In the end I'd make the decision to use for because the syntax is more readable and aesthetically nicer. As stated in the Getting Started guide, for comprehensions are a special syntactic sugar added to the language specifically to make transformations like this cleaner and easier to understand.

Upvotes: 0

Related Questions