defmodule Report do
def slow(items), do: Enum.reduce(items, [], fn x, acc -> acc ++ [x * 2] end)
def fast(items) do
items
|> Enum.reduce([], fn x, acc -> [x * 2 | acc] end)
|> Enum.reverse()
end
end
Tracing tells you what a process calls. A profiler
tells you where the cost goes: how many times each
function ran, how long it took, or how much memory it
allocated. Erlang/OTP ships :tprof (OTP 27+), which
replaces the older :eprof and :cprof with one
tool. It measures one of three things:
:call_count: calls per function, the cheapest.:call_time: time per function, the usual choice.:call_memory: words allocated per function.Time changes on every run, so this page uses counts and memory, which don't. The code under test builds a report by appending to a list, a common mistake.
defmodule Report do
def slow(items), do: Enum.reduce(items, [], fn x, acc -> acc ++ [x * 2] end)
def fast(items) do
items
|> Enum.reduce([], fn x, acc -> [x * 2 | acc] end)
|> Enum.reverse()
end
end
:tprof.profile/2 runs a function with profiling on.
By default it prints a table that includes pids;
report: :return hands back the data instead, so we
can print the rows our own way: busiest function last.
profile = fn label, fun ->
{_result, {:call_memory, rows}} =
:tprof.profile(fun, %{type: :call_memory, report: :return})
IO.puts(label)
rows
|> Enum.map(fn {mod, fun, arity, per_process} ->
calls = per_process |> Enum.map(&elem(&1, 1)) |> Enum.sum()
words = per_process |> Enum.map(&elem(&1, 2)) |> Enum.sum()
{Exception.format_mfa(mod, fun, arity), calls, words}
end)
|> Enum.sort_by(fn {_name, _calls, words} -> words end)
|> Enum.each(fn {name, calls, words} ->
IO.puts(" #{String.pad_trailing(name, 36)} calls: #{calls}, words: #{words}")
end)
end
items = Enum.to_list(1..200)
profile.("slow, 200 items:", fn -> Report.slow(items) end)
slow, 200 items:
anonymous fn/2 in Report.slow/1 calls: 200, words: 400
:erlang.++/2 calls: 200, words: 39800
Almost all the memory goes to ++. Appending copies
the whole left-hand list every time, so the 200th call
copies 199 elements. Double the input and the cost
quadruples: the work is quadratic.
more = Enum.to_list(1..400)
profile.("slow, 400 items:", fn -> Report.slow(more) end)
slow, 400 items:
anonymous fn/2 in Report.slow/1 calls: 400, words: 800
:erlang.++/2 calls: 400, words: 159603
Prepending allocates one list cell (2 words) and copies
nothing, and one Enum.reverse/1 at the end puts the
order back.
profile.("fast, 200 items:", fn -> Report.fast(items) end)
fast, 200 items:
Enum.reverse/1 calls: 1, words: 4
:lists.reverse/2 calls: 1, words: 396
anonymous fn/2 in Report.fast/1 calls: 200, words: 400
In a project you don't need to write any of this:
mix profile.tprof -e "Report.slow(Enum.to_list(1..200))"
prints the same tables (--type calls|time|memory).
On a live node, the same API takes a running process
instead of a function: :tprof.start/0, then
:tprof.enable_trace/1 with the pids or
:processes, :tprof.set_pattern/3 for the modules,
and :tprof.collect/0 after a few seconds.
Pitfall:
:call_timeand:call_memoryslow the profiled code down a lot, and profiling every process makes the whole node slower. On a production node, profile one process or one module, for a few seconds, and start with:call_count.
Pitfall: a profiler shows where one process spends its time. If the node is slow because work waits in a mailbox or a run queue, the profile looks fine. Check
message_queue_lenand reductions first (see Mailboxes and Schedulers and Reductions).
Pitfall: don't trust a micro-benchmark from a script or
iex: code evaluated there isn't compiled the same way as a module. Profile functions in compiled modules, as here, and use a benchmarking library likebencheefor timings.
Go deeper: chapter 21 of The BEAM Book by Erik Stenman, "Profiling, Monitoring, and Performance Optimization", compares the profiling tools and what each costs.