Do you want to pick up from where you left of?
Take me there

Benchee

Nie możemy po prostu przypuszczać, które funkcje są szybkie a które są powolne - aby to ustalić potrzebujemy rzeczywistych pomiarów. Tu z pomocą przychodzi analiza porównacza. W tej lekcji nauczymy się jak łatwo jest zmierzyć szybkość naszego kodu.

O Benchee

Chociaż istnieje funkcja w Erlangu, która może być użyta do bardzo podstawowego pomiaru czasu wykonania funkcji, nie jest tak latwa w użytkowaniu jak niektóre z dostępnych narzędzi. Nie daje Ci wielu pomiarów, ktore są niezbedne do prawidłowego przeprowadzenia statystyk, dlatego skorzystamy z Benchee. Benchee dostarcza nam wielu statystyk z łatwymi do porównania scenariuszami, wspaniałą cechą, która pozwala nam przetestować różne dane wejściowe na funkcjach które testujemy i kilka różnych formaterów, które możemy wykorzystać do wyświetlania naszych wyników.

Użytkowanie

Aby dodać Benchee do projektu, umieść go jako zależność w pliku mix.exs:

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

Następnie wykonujemy:

$ mix deps.get
...
$ mix compile

Pierwsze polecenie pobiera i instaluje Benchee. Możesz zostać poproszony o zainstalowanie Hex wraz z nim. Drugie kompiluje aplikację Benchee. Teraz jesteśmy gotowi napisać nasz pierwszy test wydajości!

Ważna uwaga przed rozpoczęciem: Podczas testów wydajności bardzo ważne jest, aby nie używać iex, ponieważ zachowuje się inaczej i często jest dużo wolniejsze niż to, jak twój kod jest używany w produkcji. Stwórzmy plik który nazwiemy “benchmark.exs”, a w tym pliku dodamy następujący kod:

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

Aby uruchomić nasz test wydajności, wykonujemy:

mix run benchmark.exs

Następnie w konsoli powinniśmy zobaczyć:

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

Oczywiście informacje o twoim systemie oraz rezultaty mogą być inne w zależności od specyfikacji Twojej maszyny, ale generalne informacje powinny być takie same.

Na pierwszy rzut oka sekcja Comparison pokazuje nam, że nasza wersja map.flatten jest wolniejsza o 1.85x od flat_map - jest to bardzo pomocna informacja! Spójrzmy jednak na inne statystyki, które otrzymaliśmy:

Istnieją również inne dostępne statystyki, ale te cztery są często najbardziej użyteczne i powszechnie używane do analizy porównawczej, dlatego są wyświetlane w domyślnym formaterze. Więcej informacji na temat innych dostępnych metryk można znaleźć w dokumentacji hexdocs.

Konfiguracja

Jedną z najlepszych części Benchee są wszystkie dostępne opcje konfiguracji. Zaczniemy od podstaw, ponieważ nie wymagają przykładów kodu, a następnie pokażemy, jak wykorzystać jedną z najlepszych funkcji Benchee - wejść.

Podstawy

Benchee ma wiele opcji konfiguracyjnych. W najbardziej popularnym interfejsie Benchee.run/2, są one przekazywane jako drugi argument w formie listy słów kluczowych:

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

Dostępne są następujące opcje (także udokumentowane w hexdocs).

Wejścia

Bardzo ważne jest, aby testować wydajność funkcji na danych wielkością odpowiadających danym które będą używane w produkcji. Często funkcja może zachowywać się inaczej na małych zestawach danych w porównaniu do dużych zbiorów danych! Tu z pomocą przychodzi inputs. Pozwala to na testowanie tej samej funkcji, ale różnymi rodzajami danych wejściowych. Następnie wyniku testów można porównać.

Przyjrzyjmy się więc naszemu pierwotnemu przykładowi:

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

W tym przykładzie używamy tylko jednej listy liczb całkowitych od 1 do 10,000. Zaktualizujmy to aby użyć kilku różnych wejść, dzięki czemu możemy zobaczyć, co się dzieje z mniejszymi i większymi listami. Otworzymy ten plik i zmienimy go w następujący sposób:

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
)

Zauważysz dwie różnice. Najpierw mamy mapę input zawierającą informacje o naszych danych wejściowych. Przekazujemy tę mapę jako opcję konfiguracji do Benchee.run/2.

Ponieważ nasze funkcje wymagają argumentu, musimy zaktualizować nasze funkcje tak, aby przyjmowały argument:

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

teraz mamy:

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

Uruchommy to ponownie:

mix run benchmark.exs

Teraz powinieneś zobaczyć następujące dane w konsoli:

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

Teraz możemy zobaczyć informacje o naszych benchmarkach pogrupowane według danych wejściowych. Ten prosty przykład nie dostarcza imponujących spostrzeżeń, ale może Cię zaskoczyę jak bardzo pomiary wydajności zależą od wielkości danych wejsciowych!

Formatery

Wyjście konsoli, które widzieliśmy, jest bardzo pomocne podczas pomiaru czasy wykonywania Twoich funkcji, ale to nie jedyna opcja! W tej sekcji zapoznamy się z trzema innymi formaterami, a także dowiesz się co musisz zrobić, aby napisać własny formater, jeśli chcesz.

Inne formatery

Benchee ma wbudowany formater konsolowy, co widzieliśmy już wcześniej, ale istnieją trzy inne oficjalne formaty - benchee_csv, benchee_json i benchee_html. Każdy z nich zapisuje wyniki do plików danego formatu, dzięki czemu możesz pracować z Twoimi wynikami w dowolnym formacie.

Każdy z tych formatów znajduje się w osobnej paczce, więc aby nich korzystać trzeba dodać je jako zależności do pliku mix.exs:

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

Chociaż benchee_json i benchee_csv są bardzo proste, benchee_html jest bogaty w interesujące funkcjonalności! Może pomóc Ci w prosty sposób tworzyć ładne wykresy z wynikami, a nawet je eksportować jako obrazy PNG. Wszystkie trzy formaty są dobrze udokumentowane na odpowiednich stronach GitHub.

Niestandardowe formatery

Jeśli cztery oferowane formatery nie są dla Ciebie wystarczające, możesz napisać własny. Pisanie formatera jest całkiem proste. Musisz napisać funkcję, która akceptuje strukturę % Benchee.Suite {}, a następnie możesz pobrać dowolne informacje. Informacje na temat tego, co dokładnie znajduje się w tej strukturze można znaleźć na stronie GitHub lub HexDocs. Baza kodu jest bardzo dobrze udokumentowana i czytelna.

W kolejnym przykładzie pokażemy jak niestandardowy format może wyglądać. Powiedzmy, że chcemy tylko bardzo minimalnego formatera, który drukuje średni czas wykonywania każdego scenariusza - może on wyglądać tak:

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

Następnie możemy uruchomić nasze testy wydajności w naspępujący sposób:

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

Dzięki naszemu nowemu formaterowi ukaże się nam następujący widok:

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
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!