Implement mix profile.tprof (#13605)

This commit is contained in:
Jean Klingler
2024-05-27 21:51:29 +09:00
committed by GitHub
parent ae5be3106f
commit 76fec7eda2
3 changed files with 634 additions and 9 deletions
+4 -9
View File
@@ -224,9 +224,9 @@ defmodule Mix.Tasks.Profile.Eprof do
calls_opt = Keyword.get(opts, :calls, 0)
time_opt = Keyword.get(opts, :time, 0)
call_results
|> Stream.filter(fn {_mfa, {count, _time}} -> count >= calls_opt end)
|> Stream.filter(fn {_mfa, {_count, time}} -> time >= time_opt end)
Enum.filter(call_results, fn {_mfa, {count, time}} ->
count >= calls_opt and time >= time_opt
end)
end
defp sort_results(call_results, opts) do
@@ -307,15 +307,10 @@ defmodule Mix.Tasks.Profile.Eprof do
max_lengths = Enum.map(header, &String.length/1)
Enum.reduce(rows, max_lengths, fn row, max_lengths ->
Stream.map(row, &String.length/1)
|> Stream.zip(max_lengths)
|> Enum.map(&max/1)
Enum.zip_with(row, max_lengths, fn cell, length -> String.length(cell) |> max(length) end)
end)
end
defp max({a, b}) when a >= b, do: a
defp max({_, b}), do: b
@format "~-*s ~*s ~*s ~*s ~*s~n"
defp print_row(row, column_lengths) do
+441
View File
@@ -0,0 +1,441 @@
defmodule Mix.Tasks.Profile.Tprof do
use Mix.Task
@shortdoc "Profiles the given file or expression with tprof"
@moduledoc """
Profiles the given file or expression using Erlang's `tprof` tool.
Requires Erlang/OTP27 or above.
[`:tprof`](`:tprof`) is an experimental module introduced in Erlang/OTP 27 which
provides a unified API for measuring call count, time, and allocation, and aims to
replace [`:eprof`](`:eprof`) and [`:cprof`](`:cprof`).
It can be useful when you want to discover the bottlenecks related to any of these
measurements.
Before running the code, it invokes the `app.start` task which compiles
and loads your project. After that, the target expression is profiled together
with all matching function calls using the Erlang trace BIFs. The tracing of
the function calls for that is enabled when the profiling is begun, and
disabled when profiling is stopped.
To profile the code, you can use syntax similar to the `mix run` task:
$ mix profile.tprof -e Hello.world
$ mix profile.tprof -e "[1, 2, 3] |> Enum.reverse |> Enum.map(&Integer.to_string/1)"
$ mix profile.tprof my_script.exs arg1 arg2 arg3
By default, tprof uses the `time` type, but you can profile memory too:
$ mix profile.tprof -e "Enum.map([1, 2, 3], &Integer.to_string/1)" --type memory
Call count is present with both type `time` and `memory`, but if you only need
the call count information, you can use the type `calls` which has the lowest footprint:
$ mix profile.tprof -e "Enum.map([1, 2, 3], &Integer.to_string/1)" --type calls
This task is automatically re-enabled, so you can profile multiple times
in the same Mix invocation.
## Command line options
* `--matching` - only profile calls matching the given `Module.function/arity` pattern
* `--type` - the type of profiling, `calls`, `time` or `memory` (default: `time`)
* `--calls` - filters out any results with a call count lower than this
* `--time` - filters out any results that took lower than specified (in µs), the `type` needs to be `time`
* `--memory` - filters out any results that used less memory than specified (in words), the `type` needs to be `memory`
* `--sort` - sorts the results by `calls` or by the value of `type` (default: the value of `type`)
* `--eval`, `-e` - evaluates the given code
* `--require`, `-r` - requires pattern before running the command
* `--parallel`, `-p` - makes all requires parallel
* `--no-warmup` - skips the warmup step before profiling
* `--no-compile` - does not compile even if files require compilation
* `--no-deps-check` - does not check dependencies
* `--no-archives-check` - does not check archives
* `--no-halt` - does not halt the system after running the command
* `--no-start` - does not start applications after compilation
* `--no-elixir-version-check` - does not check the Elixir version from mix.exs
## Profile output
Example output (`time` type):
Profile results of #PID<0.107.0>
# CALLS % TIME µS/CALL
Total 20 100.00 2 0.10
String.Chars.Integer.to_string/1 5 0.00 0 0.00
anonymous fn/0 in :elixir_compiler_1.__FILE__/1 1 0.00 0 0.00
Enum.each/2 1 0.00 0 0.00
Enum.reduce_range/5 3 0.00 0 0.00
:erlang.integer_to_binary/1 5 50.00 1 0.20
anonymous fn/3 in Enum.each/2 5 50.00 1 0.20
Profile done over 6 matching functions
Example output (`memory` type):
Profile results of #PID<0.107.0>
# CALLS % WORDS PER CALL
Total 6 100.00 19 3.17
Enum.each/2 1 21.05 4 4.00
:erlang.integer_to_binary/1 5 78.95 15 3.00
Profile done over 2 matching functions
Example output (`calls` type)
Profile results over all processes
# CALLS %
Total 20 100.00
anonymous fn/0 in :elixir_compiler_1.__FILE__/1 1 5.00
Enum.each/2 1 5.00
Enum.reduce_range/5 3 15.00
:erlang.integer_to_binary/1 5 25.00
String.Chars.Integer.to_string/1 5 25.00
anonymous fn/3 in Enum.each/2 5 25.00
Profile done over 6 matching functions
The default output contains data gathered from all matching functions. The first
row after the header contains the sums of the partial results and the average time
or memory usage for all the function calls listed.
The following rows contain the function call, followed by the number of times that
the function was called, then by the percentage of time/memory that the call uses,
then the total time/memory for that function in microseconds/words, and, finally,
the average time/memory per call in microseconds/words.
When `--matching` option is specified, call count tracing will be started only for
the functions matching the given pattern:
Profile results of #PID<0.106.0>
# CALLS % TIME µS/CALL
Total 5 100.00 1 0.20
String.Chars.Integer.to_string/1 5 100.00 1 0.20
Profile done over 1 matching functions
The pattern can be a module name, such as `String` to count all calls to that module,
a call without arity, such as `String.split`, to count all calls to that function
regardless of arity, or a call with arity, such as `String.split/3`, to count all
calls to that exact module, function and arity.
## Caveats
You should be aware that the code being profiled is running in an anonymous
function which is invoked by [`:tprof` module](https://www.erlang.org/doc/man/tprof.html).
Thus, you'll see some additional entries in your profile output. It is also
important to note that the profiler is stopped as soon as the code has finished running,
and this may need special attention, when: running asynchronous code as function calls which were
called before the profiler stopped will not be counted; running synchronous code as long
running computations and a profiler without a proper MFA trace pattern or filter may
lead to a result set which is difficult to comprehend.
You should expect a slowdown in your code execution using this tool since `:tprof` has
some performance impact on the execution, but the impact is considerably lower than
`Mix.Tasks.Profile.Fprof`. If you have a large system try to profile a limited
scenario or focus on the main modules or processes. The `calls` type can also be used,
which is more limited but has a lower footprint.
"""
@switches [
parallel: :boolean,
require: :keep,
eval: :keep,
config: :keep,
matching: :string,
halt: :boolean,
compile: :boolean,
deps_check: :boolean,
type: :string,
calls: :integer,
time: :integer,
memory: :integer,
sort: :string,
start: :boolean,
archives_check: :boolean,
warmup: :boolean,
elixir_version_check: :boolean,
parallel_require: :keep
]
@aliases [
r: :require,
p: :parallel,
e: :eval,
c: :config
]
@impl true
def run(args) do
{opts, head} = OptionParser.parse_head!(args, aliases: @aliases, strict: @switches)
Mix.Task.reenable("profile.tprof")
Mix.Tasks.Run.run(
["--no-mix-exs" | args],
opts,
head,
&profile_code(&1, opts),
&profile_code(File.read!(&1), opts)
)
end
defp profile_code(code_string, opts) do
opts = Enum.map(opts, &parse_opt/1)
content =
quote do
unquote(__MODULE__).profile(
fn ->
unquote(Code.string_to_quoted!(code_string))
end,
unquote(Macro.escape(opts))
)
end
# Use compile_quoted since it leaves less noise than eval_quoted
Code.compile_quoted(content)
end
defp parse_opt({:matching, matching}) do
case Mix.Utils.parse_mfa(matching) do
{:ok, [m, f, a]} -> {:matching, {m, f, a}}
{:ok, [m, f]} -> {:matching, {m, f, :_}}
{:ok, [m]} -> {:matching, {m, :_, :_}}
:error -> Mix.raise("Invalid matching pattern: #{matching}")
end
end
defp parse_opt({:type, "time"}), do: {:type, :time}
defp parse_opt({:type, "calls"}), do: {:type, :calls}
defp parse_opt({:type, "memory"}), do: {:type, :memory}
defp parse_opt({:type, other}), do: Mix.raise("Invalid type option: #{other}")
defp parse_opt({:sort, "time"}), do: {:sort, :time}
defp parse_opt({:sort, "calls"}), do: {:sort, :calls}
defp parse_opt({:sort, "memory"}), do: {:sort, :memory}
defp parse_opt({:sort, other}), do: Mix.raise("Invalid sort option: #{other}")
defp parse_opt(other), do: other
@doc """
Allows to programmatically run the `tprof` profiler on expression in `fun`.
Returns the return value of `fun`.
## Options
* `:matching` - only profile calls matching the given pattern in form of
`{module, function, arity}`, where each element may be replaced by `:_`
to allow any value
* `:type` - the type of profiling, possible values are `:time`, `:memory` or `:calls`,
(default: `:time`), see [moduledoc](`Mix.Tasks.Profile.Tprof`) for more information
* `:calls` - filters out any results with a call count lower than this
* `:time` - filters out any results that took lower than specified (in µs),
`type` needs to be `:time`
* `:memory` - filters out any results that used less memory than specified (in words),
`type` needs to be `:memory`
* `:sort` - sort the results by `:calls` or by the value of `type`
(default: the value of `type`)
* `:warmup` - if the code should be warmed up before profiling (default: `true`)
* `:set_on_spawn` - if newly spawned processes should be measured (default: `true`)
"""
@spec profile((-> result), keyword()) :: result when result: any()
def profile(fun, opts \\ []) when is_function(fun, 0) do
Mix.ensure_application!(:tools)
{type, return_value, results} = profile_and_analyse(fun, opts)
print_output(type, results)
return_value
end
defp profile_and_analyse(fun, opts) do
if Keyword.get(opts, :warmup, true) do
IO.puts("Warmup...\n")
fun.()
end
matching = Keyword.get(opts, :matching, {:_, :_, :_})
set_on_spawn = Keyword.get(opts, :set_on_spawn, true)
type = Keyword.get(opts, :type, :time)
sort_by =
case Keyword.get(opts, :sort) do
nil ->
:measurement
:calls ->
:calls
^type ->
:measurement
other ->
Mix.raise("Incompatible sort option #{inspect(other)} with type #{inspect(type)}")
end
tprof_type = to_tprof_type(type)
{return_value, {^tprof_type, traces}} =
tprof_module().profile(fun, %{
set_on_spawn: set_on_spawn,
pattern: matching,
type: tprof_type,
report: :return
})
inspected = tprof_module().inspect({tprof_type, traces}, :process, sort_by)
results =
inspected
|> Enum.map(fn {pid, {^tprof_type, measurement_total, call_results}} ->
parsed_calls =
call_results
|> filter_results(type, opts)
|> add_totals(measurement_total)
{pid, parsed_calls}
end)
{type, return_value, results}
end
defp to_tprof_type(:calls), do: :call_count
defp to_tprof_type(:time), do: :call_time
defp to_tprof_type(:memory), do: :call_memory
defp filter_results(call_results, type, opts) do
calls_opt = Keyword.get(opts, :calls, 0)
measurement_opt =
get_filter_value!(type, Keyword.get(opts, :time), Keyword.get(opts, :memory))
Enum.filter(call_results, fn {_module, _fa, count, measurement, _, _} ->
count >= calls_opt and measurement >= measurement_opt
end)
end
defp get_filter_value!(type, time, _memory) when is_integer(time) and type != :time do
Mix.raise("Incompatible use of time option with type #{inspect(type)}")
end
defp get_filter_value!(type, _time, memory) when is_integer(memory) and type != :memory do
Mix.raise("Incompatible use of memory option with type #{inspect(type)}")
end
defp get_filter_value!(:time, time, nil) when is_integer(time), do: time
defp get_filter_value!(:memory, nil, memory) when is_integer(memory), do: memory
defp get_filter_value!(_, nil, nil), do: 0
defp add_totals(call_results, measurement_total) do
{function_count, calls} =
Enum.reduce(call_results, {0, 0}, fn {_mod, _fa, count, _, _, _}, acc ->
{function_count, calls} = acc
{function_count + 1, calls + count}
end)
{function_count, call_results, calls, measurement_total}
end
defp print_output(_type, []) do
print_function_count(0)
end
defp print_output(type, results) do
Enum.each(results, &print_result(type, &1))
end
defp print_result(type, {pid, {function_count, call_results, calls, total_measurement}}) do
header = header(type)
formatted_rows = Enum.map(call_results, &format_row/1)
formatted_total = format_total(total_measurement, calls)
column_lengths = column_lengths(header, [formatted_total | formatted_rows])
IO.puts("")
print_pid_row(pid)
print_row(header, column_lengths, type)
print_row(formatted_total, column_lengths, type)
Enum.each(formatted_rows, &print_row(&1, column_lengths, type))
IO.puts("")
print_function_count(function_count)
end
defp header(:calls), do: ["#", "CALLS", "%"]
defp header(:time), do: ["#", "CALLS", "%", "TIME", "µS/CALL"]
defp header(:memory), do: ["#", "CALLS", "%", "WORDS", "PER CALL"]
defp print_pid_row(:all) do
IO.puts("Profile results over all processes")
end
defp print_pid_row(pid) when is_pid(pid) do
IO.puts("Profile results of #{inspect(pid)}")
end
defp format_row({module, {function, arity}, count, measurement, per_call, percentage}) do
mfa = Exception.format_mfa(module, function, arity)
percentage = :erlang.float_to_binary(percentage, [{:decimals, 2}])
per_call = :erlang.float_to_binary(per_call, [{:decimals, 2}])
count = Integer.to_string(count)
measurement = Integer.to_string(measurement)
[mfa, count, percentage, measurement, per_call]
end
defp format_total(total_measurement, total_count) do
per_call = :erlang.float_to_binary(divide(total_measurement, total_count), [{:decimals, 2}])
[
"Total",
Integer.to_string(total_count),
"100.00",
Integer.to_string(total_measurement),
per_call
]
end
defp divide(_, 0), do: 0.0
defp divide(t, n), do: t / n
defp column_lengths(header, rows) do
max_lengths = Enum.map(header, &String.length/1)
Enum.reduce(rows, max_lengths, fn row, max_lengths ->
Enum.zip_with(row, max_lengths, fn cell, length -> String.length(cell) |> max(length) end)
end)
end
@call_format "~-*s ~*s ~*s~n"
@measurement_format "~-*s ~*s ~*s ~*s ~*s~n"
defp print_row(row, column_lengths, type) do
to_print =
column_lengths
|> Stream.zip(Stream.map(row, &String.to_charlist/1))
|> Enum.flat_map(&Tuple.to_list/1)
case type do
:calls -> :io.format(@call_format, to_print)
_ -> :io.format(@measurement_format, to_print)
end
end
defp print_function_count(count) do
IO.puts("Profile done over #{count} matching functions")
end
# TODO remove once we require Erlang/OTP 27+
defp tprof_module do
if Code.ensure_loaded?(:tprof) do
:tprof
else
Mix.raise("mix profile.tprof requires Erlang/OTP 27 or above")
end
end
end
@@ -0,0 +1,189 @@
Code.require_file("../../test_helper.exs", __DIR__)
defmodule Mix.Tasks.Profile.TprofTest do
use MixTest.Case
import ExUnit.CaptureIO
alias Mix.Tasks.Profile.Tprof
# TODO remove once we require Erlang/OTP 27+
@moduletag skip: System.otp_release() < "27"
@expr "Enum.each(1..5, &String.Chars.Integer.to_string/1)"
test "profiles evaluated expression", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["-e", @expr])
end) =~ ~r/String\.Chars\.Integer\.to_string\/1\s+\d/
end)
end
test "profiles evaluated expression in multiple processes", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["-e", "spawn(fn -> #{@expr} end)"])
end) =~ ~r/String\.Chars\.Integer\.to_string\/1\s+\d/
end)
end
test "profiles the script", context do
in_tmp(context.test, fn ->
profile_script_name = "profile_script.ex"
File.write!(profile_script_name, @expr)
assert capture_io(fn ->
Tprof.run([profile_script_name])
end) =~ ~r/String\.Chars\.Integer\.to_string\/1\s+\d/
end)
end
test "filters based on calls count", context do
in_tmp(context.test, fn ->
result =
capture_io(fn ->
Tprof.run(["--calls", "5", "-e", @expr])
end)
assert result =~ "\nString.Chars.Integer.to_string/1"
refute result =~ "\nEnum.each/2"
end)
end
test "filters based on time", context do
in_tmp(context.test, fn ->
result =
capture_io(fn ->
Tprof.run(["--time", "50", "-e", @expr])
end)
refute result =~ "\nEnum.each/2"
end)
end
test "filters based on memory", context do
in_tmp(context.test, fn ->
result =
capture_io(fn ->
Tprof.run(["--type", "memory", "--memory", "10", "-e", @expr])
end)
assert result =~ "\n:erlang.integer_to_binary/1"
refute result =~ "\nEnum.each/2"
end)
end
test "sorts based on calls count", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["--sort", "calls", "-e", @expr])
end) =~ ~r/Enum\.each\/2.*String\.Chars\.Integer\.to_string\/1/s
end)
end
test "sorts based on memory usage", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["--type", "memory", "--sort", "calls", "-e", @expr])
end) =~ ~r/Enum\.each\/2.*:erlang\.integer_to_binary\/1/s
end)
end
test "Module matching", context do
in_tmp(context.test, fn ->
refute capture_io(fn ->
Tprof.run(["--matching", "Enum", "-e", @expr])
end) =~ ~r/String\.Chars\.Integer\.to_string\/1/
end)
end
test "Module.function matching", context do
in_tmp(context.test, fn ->
refute capture_io(fn ->
Tprof.run(["--matching", "Enum.each", "-e", @expr])
end) =~ ~r/anonymous fn\/3 in Enum\.each\/2/
end)
end
test "Module.function/arity matching", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["--matching", "Enum.each/8", "-e", @expr])
end) =~ ~r/Profile done over 0 matching functions/
end)
end
test "errors on missing files", context do
in_tmp(context.test, fn ->
message = "No files matched pattern \"non-existent\" given to --require"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-r", "non-existent"]) end)
end
message = "No files matched pattern \"non-existent\" given to --require"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-pr", "non-existent"]) end)
end
assert_raise Mix.Error, "No such file: non-existent", fn ->
capture_io(fn -> Tprof.run(["non-existent"]) end)
end
File.mkdir_p!("lib")
assert_raise Mix.Error, "No such file: lib", fn ->
capture_io(fn -> Tprof.run(["lib"]) end)
end
end)
end
test "warmup", context do
in_tmp(context.test, fn ->
assert capture_io(fn ->
Tprof.run(["-e", @expr])
end) =~ "Warmup..."
refute capture_io(fn ->
Tprof.run(["-e", @expr, "--no-warmup"])
end) =~ "Warmup..."
end)
end
test "errors on incompatible options", context do
in_tmp(context.test, fn ->
message = "Incompatible sort option :memory with type :time"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-e", @expr, "--sort", "memory"]) end)
end
message = "Incompatible sort option :time with type :calls"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-e", @expr, "--type", "calls", "--sort", "time"]) end)
end
message = "Incompatible use of memory option with type :time"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-e", @expr, "--time", "1", "--memory", "2"]) end)
end
message = "Incompatible use of time option with type :calls"
assert_raise Mix.Error, message, fn ->
capture_io(fn -> Tprof.run(["-e", @expr, "--type", "calls", "--time", "1"]) end)
end
end)
end
describe ".profile/2" do
test "returns the return value of the function call" do
capture_io(fn ->
assert 42 == Tprof.profile(fn -> 42 end)
end)
end
end
end