The benchmark
Mix.install [:benchee]
defmodule X do
def dot(map) do
[
map.x + map.y,
map.y + map.x,
map.x + map.y,
map.y + map.x,
map.x + map.y,
map.y + map.x,
]
end
def annotated(%{} = map) do
[
map.x + map.y,
map.y + map.x,
map.x + map.y,
map.y + map.x,
map.x + map.y,
map.y + map.x,
]
end
def match(%{x: x, y: y}) do
[
x + y,
y + x,
x + y,
y + x,
x + y,
y + x,
]
end
defmacrop dot(map, key) do
quote do
:erlang.map_get(unquote(key), unquote(map))
end
end
def safe_dot(map) do
[
dot(map, :x) + dot(map, :y),
dot(map, :y) + dot(map, :x),
dot(map, :x) + dot(map, :y),
dot(map, :y) + dot(map, :x),
dot(map, :x) + dot(map, :y),
dot(map, :y) + dot(map, :x),
]
end
defmacrop match(map, field) do
quote do
%{unquote(field) => value} = unquote(map)
value
end
end
def match_every_time(map) do
[
match(map, :x) + match(map, :y),
match(map, :y) + match(map, :x),
match(map, :x) + match(map, :y),
match(map, :y) + match(map, :x),
match(map, :x) + match(map, :y),
match(map, :y) + match(map, :x),
]
end
end
Benchee.run(%{
"dot" => fn -> X.dot(%{x: 1, y: 1}) end,
"annotated" => fn -> X.annotated(%{x: 1, y: 1}) end,
"erlang.map_get" => fn -> X.safe_dot(%{x: 1, y: 1}) end,
"match" => fn -> X.match(%{x: 1, y: 1}) end,
"match_every_time" => fn -> X.match_every_time(%{x: 1, y: 1}) end,
}, warmup: 1, time: 2)
And the result is:
Name ips average deviation median 99th %
match 7.46 M 133.99 ns ±22658.59% 90 ns 184 ns
match_every_time 7.33 M 136.44 ns ±23751.72% 93 ns 181 ns
annotated 7.22 M 138.59 ns ±23522.99% 92 ns 196 ns
erlang.map_get 6.67 M 149.82 ns ±21303.83% 105 ns 212 ns
dot 4.24 M 235.62 ns ±16830.96% 137 ns 266 ns
Comparison:
match 7.46 M
match_every_time 7.33 M - 1.02x slower +2.45 ns
annotated 7.22 M - 1.03x slower +4.60 ns
erlang.map_get 6.67 M - 1.12x slower +15.83 ns
dot 4.24 M - 1.76x slower +101.63 ns
Explanation
First of all, construction like map.field
get’s compiled into this by elixir compiler. This is a feature of Elixir runtime and it is caused by feature of calling functions without ()
. However, code map.field()
gets compiled to the same expression.
case map do
module when is_atom(module) -> apply(module, :field, [])
%{field: value} -> value
_ -> raise BadMapError
end
match
generates the fastest core_erlang and beam for this problem. Rule of thumb: pattern matching is always the fastest way to access the data in collections in both Erlang and Elixir.annotated
generates almost the same core_erlang asdot
version, but compiler sees that themap
variable in the function body will always be a map (otherwise it wouldn’t pass the matching in args) and it optimizes thecase
generated bydot
to just a pattern matching. So it generates the same BEAM asmatch
versionerlang.map_get
is a very unpopular way to access the key of a map, and it is the third way to do so, added in one of the latest OTP version. It is actually a separate BIF and it is slower because it performs a map type check on every calldot
is the slowest one, because it performs a type check and a jump on every key access
Conclusions
- Annotate your maps to have better performance of
.
, or at least try to express as much code in pattern matching as possible - Rewrite nested access like
map.submap.subsubmap.field
into pattern-matching, or at least use Pathex. - Use Tria optimizing compiler which performs analysis to simplify the
case
generated by.
, thus extracting the best performance for the.
expression.