Benchee

Это перевод актуальной версии оригинального урока.

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

Содержание

О Benchee

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

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

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

defp deps do
  [{:benchee, "~> 0.9", 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: macOS
CPU Information: Intel(R) Core(TM) i5-4260U CPU @ 1.40GHz
Number of Available Cores: 4
Available memory: 8.589934592 GB
Elixir 1.5.1
Erlang 20.0
Benchmark suite executing with the following configuration:
warmup: 2.00 s
time: 5.00 s
parallel: 1
inputs: none specified
Estimated total run time: 14.00 s


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

Name                  ips        average  deviation         median
flat_map           1.03 K        0.97 ms    ±33.00%        0.85 ms
map.flatten        0.56 K        1.80 ms    ±31.26%        1.60 ms

Comparison:
flat_map           1.03 K
map.flatten        0.56 K - 1.85x slower

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

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

  • ips — “итераций в секунду” — показывает, сколько раз функция успеет выполниться в течение одной секунды. Чем больше этот показатель, тем лучше.
  • average — среднее — среднее время выполнения данной функции. Чем меньше, тем лучше.
  • deviation — отклонение — значение стандартного отклонения. Показывает, насколько результаты каждой отдельной итерации отличаются от среднего. Измеряется в процентах от среднего значения.
  • median — медиана — если отсортировать все замеры, медиана будет центральным значением (или средним из двух центральных, если количество измерений чётное). Из-за изменчивости окружения, медиана — более стабильный показатель, чем среднее значение. К тому же она надежнее отразит поведение вашего кода в боевых условиях. Чем меньше медиана, тем лучше.

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

Настройка

Одна из лучших сторон 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
  ]
)

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

  • warmup — прогрев — время в секундах, в течение которого сценарий будет выполняться “в холостую”, прежде чем начнутся реальные замеры производительности. Это имитирует “прогрев” реальной системы. Значение по умолчанию — 2.
  • time — время в секундах, в течение которого каждый отдельный сценарий будет исполняться с выполнением измерений. По умолчанию равно 5.
  • inputs — ассоциативный массив со строками, в котором ключи отвечают за название параметра, а значения — за сами передаваемые параметры. По умолчанию равно nil. Мы детальнее опишем этот пункт в следующей секции.
  • parallel — количество процессов, которое следует использовать при тестировании. Так, если мы передадим parallel: 4, то создадутся 4 процесса, каждый из которых будет выполнять одну и ту же функцию на протяжении time секунд. Когда они закончат, 4 новых процесса будут запущены уже для следующей функции. Это позволит собрать больше данных за то же время, но при этом создаст некоторую нагрузку на систему, что может повлиять на результаты. Этот параметр можно использовать для симуляции системы под нагрузкой, что иногда полезно. Однако надо помнить, что параллельное выполнение может непредсказуемо повлиять на результаты, так что использовать его нужно с осторожностью. Значение по умолчанию: 1 (т.е. без параллельного выполнения).
  • formatters — форматтеры — список форматирующих функций, который вы бы хотели использовать для отображения результата выполнения Benchee.run/2. Функции должны принимать один аргумент (набор данных) и затем с его помощью отображать результат. По умолчанию используется встроенный консольный форматтер Benchee.Formatters.Console.output/1. Мы подробнее рассмотрим форматтеры чуть позже.
  • print — ассоциативный массив или ключевой список со следующими опциями-атомами в качестве ключей и true или false в качестве значений. С помощью этого параметра мы можем указывать, какие метрики должны быть выведены в ходе стандартного выполнения теста. Все опции включены по умолчанию (имеют значение true). Вот они:
    • benchmarking — выводится, когда Benchee запускает новый тест.
    • configuration — краткий список всех настроек, включая прогнозируемое время выполнения. Выводится перед началом теста.
    • first_warning — предупреждения выводятся в случае, если функция выполняется слишком быстро, что потенциально может привести к неточным результатам.
  • console — ассоциативный массив или ключевой список со следующими опциями-атомами в качестве ключей и произвольными значениями. Доступные опции:
    • comparison — показывать ли сравнение результатов разных тестов (х в столько-то раз медленее, чем y). По умолчанию true, но можно отключить при помощи false.
    • unit_scaling — масштабирование — стратегия выбора единиц измерения для времени и количества. Когда масштабирование включено, Benchee подыщет наиболее подходящее отображение для чисел. Например, 1_200_000 масштабируется в 1,2 М, а 800_000 в 800 К. Стратегия масштабирования определяет то, как Benchee будет выбирать отображение числовых значений в случаях, когда “наилучший” вид разный для разных чисел. Всего есть четыре стратегии, у каждой — имя-атом. По умолчанию используется :best:
      • best — будет выбрано наиболее часто встречающееся сокращение. В случае совпадения, будет выбрано наибольшее.
      • largest — среди наилучших сокращений будет выбрано наибольшее.
      • smallest — среди наилучших будет выбрано наименьшее.
      • none — масштабирование не будет использоваться вовсе. Время будет выводится в микросекундах, а количество ips — без тысячных сокращений.

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

Очень важно тестировать функции с использованием данных, похожих на те, с которыми они скорее всего столкнутся в реальном мире. Часто функция может вести себя по-разному на маленьких и больших наборах данных. Специально для таких случаев 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: macOS
CPU Information: Intel(R) Core(TM) i5-4260U CPU @ 1.40GHz
Number of Available Cores: 4
Available memory: 8.589934592 GB
Elixir 1.5.1
Erlang 20.0
Benchmark suite executing with the following configuration:
warmup: 2.00 s
time: 5.00 s
parallel: 1
inputs: large list, medium list, small list
Estimated total run time: 2.10 min

Benchmarking with input large list:
Benchmarking flat_map...
Benchmarking map.flatten...

Benchmarking with input medium list:
Benchmarking flat_map...
Benchmarking map.flatten...

Benchmarking with input small list:
Benchmarking flat_map...
Benchmarking map.flatten...


##### With input large list #####
Name                  ips        average  deviation         median
flat_map             6.29      158.93 ms    ±19.87%      160.19 ms
map.flatten          4.80      208.20 ms    ±23.89%      200.11 ms

Comparison:
flat_map             6.29
map.flatten          4.80 - 1.31x slower

##### With input medium list #####
Name                  ips        average  deviation         median
flat_map           1.34 K        0.75 ms    ±28.14%        0.65 ms
map.flatten        0.87 K        1.15 ms    ±57.91%        1.04 ms

Comparison:
flat_map           1.34 K
map.flatten        0.87 K - 1.55x slower

##### With input small list #####
Name                  ips        average  deviation         median
flat_map         122.71 K        8.15 μs   ±378.78%        7.00 μs
map.flatten       86.39 K       11.58 μs   ±680.56%       10.00 μs

Comparison:
flat_map         122.71 K
map.flatten       86.39 K - 1.42x slower

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

Форматтеры

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

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

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

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

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

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

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

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

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

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: macOS
CPU Information: Intel(R) Core(TM) i5-4260U CPU @ 1.40GHz
Number of Available Cores: 4
Available memory: 8.589934592 GB
Elixir 1.5.1
Erlang 20.0
Benchmark suite executing with the following configuration:
warmup: 2.00 s
time: 5.00 s
parallel: 1
inputs: none specified
Estimated total run time: 14.00 s


Benchmarking flat_map...
Benchmarking map.flatten...
Average for flat_map: 851.8840109326956
Average for map.flatten: 1659.3854339873628