Mix.install([
{:beam_file, "~> 0.6.0"},
{:benchee_dsl, "~> 0.5.3"},
{:benchee_markdown, "~> 0.3.3"}
])
defmodule BeamHelper do
def abstract_code(input) do
BeamFile.abstract_code!(input) |> clean_abstract_code()
end
def erl_code(input) do
abstract_code(input)
|> :erl_syntax.form_list()
|> :erl_prettypr.format()
|> to_string()
end
# remove __info__/1 related code
def clean_abstract_code(entries) do
Enum.reject(entries, fn
{:function, _, :__info__, _, _} -> true
{:attribute, _, :spec, {{:__info__, _}, _}} -> true
_ -> false
end)
end
@info_funs [:__info__, :"-inlined-__info__/1-", :module_info]
def byte_code(input) do
BeamFile.byte_code!(input) |> elem(5) |> Enum.reject(&(elem(&1, 1) in @info_funs))
end
end
- Jean Klingler - https://github.com/sabiwara
- Software engineer at RESTAR Inc - building a B2B SaaS product for Real-Estate
- In love with Elixir since 2020
Let's see some compiler optimizations - both from Elixir and Erlang - in action!
We'll use two libraries:
benchee
(throughbenchee_dsl
) for benchmarkingbeam_lib
(through our tiny wrapper) to inspect compiled code
We'll be able to see each of the compiler steps:
elixir_code
|> expand() # elixir code again - macro etc expanded
|> to_erlang() # erlang code
|> to_byte_code() # byte code
map_example =
defmodule MapExample do
# which is faster??
def with_merge(map, x) do
Map.merge(map, %{a: x, b: x})
end
def with_put(map, x) do
map |> Map.put(:a, x) |> Map.put(:b, x)
end
end
{:module, name, _binary, _bindings} =
defmodule Benchmark do
use BencheeDsl.Benchmark
config(
time: 2,
pre_check: true,
print: [benchmarking: false, fast_warning: false, configuration: false]
)
inputs(map: %{a: 3, c: 4})
job(with_merge(map)) do
MapExample.with_merge(map, 1)
end
job(with_put(map)) do
MapExample.with_put(map, 1)
end
end
BencheeDsl.Livebook.benchee_config() |> name.run() |> BencheeDsl.Livebook.render()
BeamFile.elixir_code!(map_example) |> IO.puts()
Both Map.put/3
and Map.merge/2
are inlined by the compiler as can be seen here:
Map
:
@compile {:inline, fetch: 2, fetch!: 2, get: 2, put: 3, delete: 2, has_key?: 2, replace!: 3}
...
defdelegate merge(map1, map2), to: :maps
Let's get one level deeper: erlang code.
&Map.merge/2
BeamHelper.erl_code(map_example) |> IO.puts()
% in Erlang
X = 1,
Map = #{a => 3, c => 4},
Map#{a => X, b => X}.
- The expansion step inlined both functions to use the original
:maps
counterpart - The Elixir compiler translated both as a
map_field_assoc
Final Answer: No difference.
Let's check a pure Elixir construct: for
for_example =
defmodule ForExample do
def return_used(xs) do
for x <- xs, x > 0, do: Process.put(:x, x)
end
def return_unused(xs) do
for x <- xs, x > 0, do: Process.put(:x, x)
:ok
end
end
ForExample.return_used([1, 2, 3])
BeamHelper.erl_code(for_example) |> IO.puts()
If the result of for
is not used, e.g. not the last expression of a block, the list never even gets built!
{:module, name, _binary, _bindings} =
defmodule ForBenchmark do
use BencheeDsl.Benchmark
config(
time: 2,
memory_time: 0.5,
pre_check: true,
print: [benchmarking: false, fast_warning: false, configuration: false]
)
inputs(list: Enum.to_list(1..1000))
job(unused(map)) do
ForExample.return_unused(map)
end
job(used(map)) do
ForExample.return_used(map)
end
end
BencheeDsl.Livebook.benchee_config() |> name.run() |> BencheeDsl.Livebook.render()
if_example =
defmodule IfExample do
def operator(x) do
if x > 0, do: x
end
def arbitrary_fun(x) do
if gt_zero?(x), do: x
end
defp gt_zero?(x), do: x > 0
end
BeamHelper.erl_code(if_example) |> IO.puts()
This is the optimize_boolean
optimization for the Elixir compiler.
quote do
if x > 0, do: x
end
|> Macro.expand(__ENV__)
literal_example =
defmodule LiteralExample do
def sigil, do: ~D[2020-01-01]
def function_call, do: Date.new!(2020, 1, 1)
end
LiteralExample.function_call()
{:module, name, _binary, _bindings} =
defmodule LiteralBenchmark do
use BencheeDsl.Benchmark
config(
time: 1,
memory_time: 0.5,
pre_check: true,
print: [benchmarking: false, fast_warning: false, configuration: false]
)
job(sigil) do
LiteralExample.sigil()
end
job(function_call) do
LiteralExample.function_call()
end
end
BencheeDsl.Livebook.benchee_config() |> name.run() |> BencheeDsl.Livebook.render()
BeamFile.elixir_code!(literal_example) |> IO.puts()
BeamHelper.byte_code(literal_example)
- The Elixir compiler can expand sigils like dates or regexes at compile-time instead of runtime
- The Erlang compiler can just reuse literals (even lists, maps, ...) since they are immutable!
- Will also use less memory
:erts_debug.same(LiteralExample.sigil(), LiteralExample.sigil())
:erts_debug.same(LiteralExample.function_call(), LiteralExample.function_call())
- It is possible to understand what the compiler is doing to understand code performance
- Use sigil literals when possible instead of function calls
- In case of doubt, always benchmark!
Thank you for your attention