При изучении скорости и оптимизации очень легко получить дико неверные результаты . В частности, вы не можете сказать, что один вариант быстрее другого, не упоминая версию компилятора и режим оптимизации вашей настройки бенчмаркинга. Даже в этом случае современные процессоры настолько сложны, что в них предусмотрены предикторы ветвления на основе нейронной сети, не говоря уже о всех видах кэшей, поэтому даже при тщательной настройке результаты сравнительного анализа будут размытыми.
Что, как говорится...
Бенчмаркинг - наш друг.
criterion
это пакет, который предоставляет расширенные инструменты для тестирования. Я быстро набросал эталон, как это:
module Main where
import Criterion
import Criterion.Main
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
setupEnv = do
let xs = [1 .. 10^7] :: [Int]
return xs
benches xs =
[ bench "slow?" $ nf myButLast xs
, bench "decent?" $ nf myButLast' xs
, bench "fast?" $ nf myButLast'' xs
, bench "match2" $ nf butLast2 xs
]
main = defaultMain
[ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]
Как видите, я добавил вариант, который явно соответствует двум элементам одновременно, но в остальном это тот же самый код. Я также запускаю тесты в обратном порядке, чтобы знать о смещении из-за кеширования. Итак, давайте побежим и посмотрим!
% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5
% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time 54.83 ms (54.75 ms .. 54.90 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.86 ms (54.82 ms .. 54.93 ms)
std dev 94.77 μs (54.95 μs .. 146.6 μs)
benchmarking main/decent?
time 794.3 ms (32.56 ms .. 1.293 s)
0.907 R² (0.689 R² .. 1.000 R²)
mean 617.2 ms (422.7 ms .. 744.8 ms)
std dev 201.3 ms (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)
benchmarking main/fast?
time 84.60 ms (84.37 ms .. 84.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 84.46 ms (84.25 ms .. 84.77 ms)
std dev 435.1 μs (239.0 μs .. 681.4 μs)
benchmarking main/match2
time 54.87 ms (54.81 ms .. 54.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.85 ms (54.81 ms .. 54.92 ms)
std dev 104.9 μs (57.03 μs .. 178.7 μs)
benchmarking main/match2
time 50.60 ms (47.17 ms .. 53.01 ms)
0.993 R² (0.981 R² .. 0.999 R²)
mean 60.74 ms (56.57 ms .. 67.03 ms)
std dev 9.362 ms (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)
benchmarking main/fast?
time 69.38 ms (56.64 ms .. 78.73 ms)
0.948 R² (0.835 R² .. 0.994 R²)
mean 108.2 ms (92.40 ms .. 129.5 ms)
std dev 30.75 ms (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)
benchmarking main/decent?
time 770.8 ms (345.9 ms .. 1.004 s)
0.967 R² (0.894 R² .. 1.000 R²)
mean 593.4 ms (422.8 ms .. 691.4 ms)
std dev 167.0 ms (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)
benchmarking main/slow?
time 54.87 ms (54.77 ms .. 55.00 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.95 ms (54.88 ms .. 55.10 ms)
std dev 185.3 μs (54.54 μs .. 251.8 μs)
Похоже, наша "медленная" версия совсем не медленная! И тонкости сопоставления с образцом ничего не добавляют. (Небольшое ускорение, которое мы видим между двумя последовательными прогонами, match2
я приписываю эффектам кэширования.)
Есть способ получить больше «научных» данных: мы можем -ddump-simpl
взглянуть на то, как компилятор видит наш код.
Осмотр промежуточных сооружений - наш друг.
«Ядро» является внутренним языком GHC. Каждый исходный файл Haskell упрощается до Core перед преобразованием в окончательный функциональный граф для выполнения системой времени выполнения. Если мы посмотрим на этот промежуточный этап, он скажет нам, что myButLast
и butLast2
эквивалентны. Нужно посмотреть, поскольку на этапе переименования все наши красивые идентификаторы случайным образом искажаются.
% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done
module A1 where
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
module A2 where
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
module A3 where
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
module A4 where
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)
Кажется, что A1
и A4
являются наиболее похожими. Тщательное обследование покажет , что на самом деле кодовые структуры в A1
и A4
идентичны. Это A2
и A3
они одинаковы, также разумно, так как оба определены как состав из двух функций.
Если вы собираетесь тщательно изучить core
вывод, имеет смысл также указывать такие флаги , как -dsuppress-module-prefixes
и -dsuppress-uniques
. Они делают его намного проще для чтения.
Короткий список наших врагов тоже.
Итак, что может пойти не так с бенчмаркингом и оптимизацией?
ghci
Будучи разработанным для интерактивного воспроизведения и быстрой итерации, компилирует исходный код Haskell для определенного вида байтового кода, а не для конечного исполняемого файла, и избегает дорогих оптимизаций в пользу более быстрой перезагрузки.
- Профилирование кажется хорошим инструментом для анализа производительности отдельных фрагментов сложной программы, но оно может настолько сильно испортить оптимизацию компилятора, что результаты будут на несколько порядков ниже базовых.
- Ваша гарантия - профилировать каждый небольшой кусочек кода как отдельный исполняемый файл со своим собственным средством запуска тестов.
- Сборка мусора настраивается. Только сегодня была выпущена новая важная функция. Задержки при сборке мусора будут влиять на производительность способами, которые трудно предсказать.
- Как я уже упоминал, разные версии компилятора будут создавать разный код с разной производительностью, поэтому вы должны знать, какую версию пользователь вашего кода, скорее всего, будет использовать для его сборки, и сравниться с ней, прежде чем давать какие-либо обещания.
Это может выглядеть грустно. Но это действительно не то, что должно волновать программиста на Haskell, большую часть времени. Реальная история: у меня есть друг, который только недавно начал изучать Haskell. Они написали программу для численного интегрирования, и она была медлительной. Поэтому мы сели вместе и написали категорическое описание алгоритма с диаграммами и прочим. Когда они переписали код, чтобы привести его в соответствие с абстрактным описанием, он волшебным образом стал быстрым, как и гепард, и также сократил объем памяти. Мы рассчитали π в кратчайшие сроки. Мораль истории? Идеальная абстрактная структура, и ваш код будет оптимизирован.
init
был оптимизирован, чтобы избежать «распаковки» списка несколько раз.