Skip to content

Instantly share code, notes, and snippets.

@sabiwara
Created April 1, 2024 11:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sabiwara/7097b20cd648631d65d0e0b6bb104881 to your computer and use it in GitHub Desktop.
Save sabiwara/7097b20cd648631d65d0e0b6bb104881 to your computer and use it in GitHub Desktop.
Tokyo.ex LT: Compiler optimizations

Tokyo.ex LT: Compiler optimizations

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

About me

  • 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

Compiler optimizations - A peak under the hood

Let's see some compiler optimizations - both from Elixir and Erlang - in action!

We'll use two libraries:

  • benchee (through benchee_dsl) for benchmarking
  • beam_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

Example 1: maps

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.

Example 2: For

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()

Example 3: If

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__)

Example 4: literals

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())

Conclusion

  • 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment