Знаходячи останній, але другий елемент списку, чому використання "останнього" є найшвидшим серед них?


10

Нижче наведено 3 функції, які знаходять останній, але другий елемент у списку. Той, хто використовує, last . initздається набагато швидшим, ніж решта. Я не можу зрозуміти, чому.

Для тестування я використав список вхідних даних [1..100000000](100 мільйонів). Останній працює майже миттєво, тоді як інші займають кілька секунд.

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

5
initоптимізовано, щоб уникнути "розпакування" списку кілька разів.
Віллем Ван Онсем

1
@WillemVanOnsem, але чому це myButLastнабагато повільніше ?. Здається, це не розпаковується жодного списку, а просто переходить його як initфункція ...
lsmor

1
@Ismor: це [x, y]є абревіатурою (x:(y:[])), тому він розпаковує зовнішні недоліки, другі мінуси, і перевіряє , якщо хвіст другого consє []. Крім того, другий пункт знову відпакує список у (x:xs). Так, розпакування досить ефективно, але, звичайно, якщо це трапляється дуже часто, це сповільнить процес.
Віллем Ван Онсем

1
Дивлячись на hackage.haskell.org/package/base-4.12.0.0/docs/src/… , здається, оптимізація така, initяка не повторно перевіряється, чи є її аргументом однодисковий чи порожній список. Після запуску рекурсії він просто передбачає, що перший елемент буде включений на результат рекурсивного виклику.
чепнер

2
@WillemVanOnsem Я думаю, що розпакування, ймовірно, не тут полягає в проблемі: GHC робить спеціалізацію схеми викликів, яка повинна автоматично надавати вам оптимізовану версію myButLast. Я думаю, що скоріше за все перерахуйте синтез, який винен у прискоренні.
oisdk

Відповіді:


9

Вивчаючи швидкість та оптимізацію, дуже легко отримати дикі неправильні результати . Зокрема, ви не можете сказати, що один варіант швидший, ніж інший, не згадуючи версію компілятора та режим оптимізації вашого налаштування бенчмаркінгу. Навіть тоді сучасні процесори настільки складні, що мають функцію прогнозування гілок на основі нейронної мережі, не кажучи вже про всі види кеш-пам'яті, тому навіть при ретельному налаштуванні результати бенчмаркінгу будуть розмитими.

Це сумно...

Бенчмаркінг - наш друг.

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. Вони написали програму для чисельної інтеграції, і вона була повільною. Тож ми сіли разом і написали категоричний опис алгоритму з діаграмами та іншим матеріалом. Коли вони переписали код для узгодження з абстрактним описом, він магічно став, мов, гепардом швидким і тонким на пам'ять. Ми обчислили π за короткий час. Мораль розповіді? Ідеальна абстрактна структура, і ваш код оптимізує себе.


Дуже інформативний, а також трохи непосильний для мене на цьому етапі. У цьому випадку всі «бенчмаркінг», які я робив, виконував усі функції для 100 мільйонів списку елементів і зауважував, що один займає більше часу, ніж інший. Тест із критерієм здається досить корисним. Крім того, ghciсхоже, це дає різні результати (з точки зору швидкості) порівняно з тим, щоб зробити exe першим, як ви сказали.
storm125
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.