Benchee

Невозможно угадать, какие функции работают быстро, а какие — медленно. Нам нужны настоящие измерения, получаемые при помощи тестирования производительности. В этом уроке мы узнаем, как легко измерять скорость нашего кода.

О Benchee

Несмотря на то, что в Erlang уже есть функция, которую можно использовать для простейших измерений времени выполнения кода, она не очень удобна по сравнению с некоторыми существующими инструментами, а также не поддерживает многократные измерения, необходимые для сбора полноценной статистики. Поэтому мы будем использовать Benchee. Benchee позволяет нам использовать широкий набор метрик с возможностью легко сопоставлять результаты разных сценариев — что невероятно полезно при сравнении вызовов функций с несколькими наборами входных данных. Результаты можно красиво отобразить при помощи нескольких встроенных форматтеров, либо же написать свой собственный.

Использование

Чтобы использовать Benchee в своём проекте, просто добавим её в качестве зависимости в mix.exs:

defp deps do
  [{:benchee, "~> 1.0", only: :dev}]
end

Теперь вызовем:

$ mix deps.get
...
$ mix compile

Первая команда загрузит и установит Benchee. Возможно, понадобится также установить Hex. Вторая команда скомпилирует Benchee. Всё готово к написанию нашего первого теста!

Важное замечание: тестируя производительность, очень важно не использовать интерактивный режим iex, поскольку он ведет себя иначе и зачастую гораздо медленнее того, что ваш код покажет в боевых условиях. Давайте создадим файл benchmark.exs со следующим содержимым:

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(%{
  "flat_map" => fn -> Enum.flat_map(list, map_fun) end,
  "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
})

Теперь, чтобы произвести замер производительности, исполним код в файле:

$ mix run benchmark.exs

В консоли должно появиться что-то похожее:

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking flat_map...
Benchmarking map.flatten...

Name                  ips        average  deviation         median         99th %
flat_map           2.40 K      416.00 μs    ±12.88%      405.67 μs      718.61 μs
map.flatten        1.24 K      806.20 μs    ±20.65%      752.52 μs     1186.28 μs

Comparison:
flat_map           2.40 K
map.flatten        1.24 K - 1.94x slower +390.20 μs

Разумеется, ваша системная информация и результаты могут отличаться в зависимости от характеристик вашего компьютера. Но в целом вся эта информация должна быть представлена.

Взглянув на секцию Comparison, сразу становится понятно, что реализация map.flatten в 1,94 раз медленнее, чем flat_map. Также видно, что она в среднем на 390 микросекунд медленнее. Очень полезно! Но давайте взглянем и на другие метрики:

Существуют также другие метрики, но на эти пять, как правило, обращают наибольшее внимание при тестировании производительности, поэтому они выводятся в результатах стандартным форматтером. Узнать больше о других метриках можно в документации.

Настройка

Одна из лучших сторон Benchee — это большое количество настроек. Мы пройдемся сперва по простейшим из них, т.к. они понятны даже без примеров кода. Затем мы посмотрим, как использовать одну из самых крутых возможностей Benchee — наборы входных данных.

Основы

Benchee использует для настройки параметры конфигурации. В основном интерфейсе Benchee.run/2 они передаются вторым аргументом в виде опционального ключевого списка.

Benchee.run(
  %{"example function" => fn -> "hi!" end},
  warmup: 4,
  time: 10,
  inputs: nil,
  parallel: 1,
  formatters: [&Benchee.Formatters.Console.output/1],
  print: [
    benchmarking: true,
    configuration: true,
    fast_warning: true
  ],
  console: [
    comparison: true,
    unit_scaling: :best
  ]
)

Список доступных параметров (также описан в документации):

Наборы входных данных

Очень важно тестировать функции с использованием данных, похожих на те, с которыми они скорее всего столкнутся в реальном мире. Часто функция может вести себя по-разному на маленьких и больших наборах данных. Специально для таких случаев Benchee поддерживает наборы входных данных (inputs)! Они позволяют нам тестировать одну и ту же функцию со столькими наборами входных данных, сколько нам понадобится, а затем отобразить их в результатах.

Посмотрим на наш первый пример:

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(%{
  "flat_map" => fn -> Enum.flat_map(list, map_fun) end,
  "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
})

В нём мы используем один-единственный список целых чисел от 1 до 10 000. Давайте вместо него используем несколько разных аргументов, чтобы можно было сравнить результаты на малых и больших списках. Должно получиться так:

map_fun = fn i -> [i, i * i] end

inputs = %{
  "small list" => Enum.to_list(1..100),
  "medium list" => Enum.to_list(1..10_000),
  "large list" => Enum.to_list(1..1_000_000)
}

Benchee.run(
  %{
    "flat_map" => fn list -> Enum.flat_map(list, map_fun) end,
    "map.flatten" => fn list -> list |> Enum.map(map_fun) |> List.flatten() end
  },
  inputs: inputs
)

Можно заметить два существенных различия. Во-первых, мы создали ассоциативный массив inputs, в котором хранится информация о наборах входных данных. Во-вторых, мы передаём его в виде настройки в Benchee.run/2.

И так как теперь наша функция должна уметь принимать входной аргумент, мы должны заменить:

fn -> Enum.flat_map(list, map_fun) end

на:

fn(list) -> Enum.flat_map(list, map_fun) end

Снова запустим наши измерения:

$ mix run benchmark.exs

Теперь должно получиться что-то такое:

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: large list, medium list, small list
Estimated total run time: 42 s

Benchmarking flat_map with input large list...
Benchmarking flat_map with input medium list...
Benchmarking flat_map with input small list...
Benchmarking map.flatten with input large list...
Benchmarking map.flatten with input medium list...
Benchmarking map.flatten with input small list...

##### With input large list #####
Name                  ips        average  deviation         median         99th %
flat_map            13.20       75.78 ms    ±25.15%       71.89 ms      113.61 ms
map.flatten         10.48       95.44 ms    ±19.26%       96.79 ms      134.43 ms

Comparison:
flat_map            13.20
map.flatten         10.48 - 1.26x slower +19.67 ms

##### With input medium list #####
Name                  ips        average  deviation         median         99th %
flat_map           2.66 K      376.04 μs    ±23.72%      347.29 μs      678.17 μs
map.flatten        1.75 K      573.01 μs    ±27.12%      512.48 μs     1076.27 μs

Comparison:
flat_map           2.66 K
map.flatten        1.75 K - 1.52x slower +196.98 μs

##### With input small list #####
Name                  ips        average  deviation         median         99th %
flat_map         266.52 K        3.75 μs   ±254.26%        3.47 μs        7.29 μs
map.flatten      178.18 K        5.61 μs   ±196.80%        5.00 μs       10.87 μs

Comparison:
flat_map         266.52 K
map.flatten      178.18 K - 1.50x slower +1.86 μs

Мы видим результаты замеров, сгруппированные по входным данным. В этом простом примере не видно никакой гигантской разницы, но в реальном мире производительность может о-о-очень отличаться в зависимости от размера данных.

Форматтеры

Консольный вывод, который мы видели прежде, — отличное начало, но это не единственный доступный вариант. В этой секции мы взглянем на три других форматтера, а также узнаем, что нужно сделать, чтобы написать свой.

Другие форматтеры

Помимо консольного форматтера, существует еще три - benchee_csv, benchee_json и benchee_html. Все они делают именно то, что можно было бы ожидать исходя из их названия: пишут результат в файлы соответствующего формата, чтобы затем с ними можно было работать любым удобным способом.

Каждый из этих форматтеров поставляется отдельным пакетом, поэтому, для использования, их нужно добавить в качестве зависимостей в mix.exs:

defp deps do
  [
    {:benchee_csv, "~> 1.0", only: :dev},
    {:benchee_json, "~> 1.0", only: :dev},
    {:benchee_html, "~> 1.0", only: :dev},
  ]
end

В то время как benchee_json и benchee_csv очень просты, benchee_html можно назвать крайне продвинутым! Он может с легкостью строить красивые графики и диаграммы, и даже позволяет экспортировать их в изображение формата PNG. Можете посмотреть пример вывода html, если интересно - он включает в себя графики вроде этого:

benchee_html graph export sample

Для всех трёх форматтеров написана хорошая документация на их страницах на GitHub, поэтому мы не будем вдаваться в детали.

Собственные форматтеры

Если ни один из имеющихся форматтеров не подходит, всегда можно написать собственный. Сделать это несложно. Надо написать функцию, которая будет принимать структуру %Benchee.Suite{}, из которой она в дальнейшем будет доставать любую нужную информацию. Подробнее о том, что именно находится в структуре, можно почитать на GitHub или в HexDocs. Код очень хорошо документирован и легко читается, полезно, чтобы знать, какого рода информация доступна для написания собственных форматтеров.

Вы также можете написать более полноценный форматтер, который реализует поведение Benchee.Formatter, но тут мы остановимся на простой функции.

Для примера продемонстрируем, как можно легко и быстро написать собственный форматтер. Предположим, что мы хотим супер-минималистичный форматтер, который просто выводит среднее время выполнения для каждого сценария. Это будет выглядеть примерно так:

defmodule Custom.Formatter do
  def output(suite) do
    suite
    |> format
    |> IO.write()

    suite
  end

  defp format(suite) do
    Enum.map_join(suite.scenarios, "\n", fn scenario ->
      "Average for #{scenario.job_name}: #{scenario.run_time_statistics.average}"
    end)
  end
end

И теперь запустим измерения:

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(
  %{
    "flat_map" => fn -> Enum.flat_map(list, map_fun) end,
    "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
  },
  formatters: [&Custom.Formatter.output/1]
)

Наш форматтер должен вывести следующее:

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking flat_map...
Benchmarking map.flatten...
Average for flat_map: 419433.3593474056
Average for map.flatten: 788524.9366408596

Память

Мы почти всё рассмотрели, но обошли стороной одну из крутейших возможностей Benchee: замеры памяти!

Benchee может измерять потребление памяти, но имея доступ только к процессу, в котором запущен замер. Сейчас Benchee не может отслеживать потребление памяти другими процессами (к примеру, пулами воркеров).

Потребление памяти включает всю память, использованную вашими замерами, и также память, которая была освобождена сборщиком мусора, так что не обязательно соответствует максимальному размеру памяти процесса.

Как этим пользоваться? Просто используйте опцию :memory_time!

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 0 ns
time: 0 ns
memory time: 1 s
parallel: 1
inputs: none specified
Estimated total run time: 2 s

Benchmarking flat_map...
Benchmarking map.flatten...

Memory usage statistics:

Name           Memory usage
flat_map          624.97 KB
map.flatten       781.25 KB - 1.25x memory usage +156.28 KB

**All measurements for memory usage were the same**

Как видите, Benchee не пытается отобразить все метрики, так как все собранные замеры одинаковые. На самом деле, так часто бывает, если ваши функции не подвергаются случайности в том или ином виде. И какой прок был бы от всех этих метрик, если бы они выдавали одну и ту же цифру каждый раз?

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!