Вивчаючи швидкість та оптимізацію, дуже легко отримати дикі неправильні результати . Зокрема, ви не можете сказати, що один варіант швидший, ніж інший, не згадуючи версію компілятора та режим оптимізації вашого налаштування бенчмаркінгу. Навіть тоді сучасні процесори настільки складні, що мають функцію прогнозування гілок на основі нейронної мережі, не кажучи вже про всі види кеш-пам'яті, тому навіть при ретельному налаштуванні результати бенчмаркінгу будуть розмитими.
Це сумно...
Бенчмаркінг - наш друг.
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
поглянути на те, як компілятор бачить наш код.
Огляд проміжних конструкцій - наш друг.
"Core" - це внутрішня мова 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
оптимізовано, щоб уникнути "розпакування" списку кілька разів.