Чи має Haskell рекурсивно-рекурсивну оптимізацію?


89

Сьогодні я виявив команду "time" в unix і подумав, що використаю її, щоб перевірити різницю у виконанні між рекурсивними хвостами та нормальними рекурсивними функціями в Haskell.

Я написав такі функції:

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

Вони дійсні, маючи на увазі, що вони були виключно для використання з цим проектом, тому я не потрудився перевірити нулі чи від’ємні числа.

Однак після написання основного методу для кожного, їх компіляції та запуску за допомогою команди "time", обидва мали однакові строки виконання із звичайною рекурсивною функцією, яка витинає хвіст рекурсивною. Це суперечить тому, що я чув про відносно-рекурсивну оптимізацію lisp. Яка причина цього?


8
Я вважаю, що TCO - це оптимізація для збереження певного стека викликів, це не означає, що ви заощадите трохи часу процесора. Виправте мене, якщо помилився.
Jerome

3
Не перевіряв це з lisp, але підручник, який я прочитав, мав на увазі, що налаштування стека спричинило за собою більше витрат процесора, тоді як компільоване до ітераційного хвоста-рекурсивне рішення не витрачало жодної енергії (часу) на це і, отже, був більш ефективним.
haskell-мошенник

1
@Jerome - це залежить від багатьох речей, але зазвичай в дію входять і кеш-пам’яті, тому TCO зазвичай також виробляє швидшу програму ..
Kristopher Micinski

Яка причина цього? Одним словом: лінь.
Dan Burton

Цікаво, що facце більш-менш те, як ghc обчислює product [n,n-1..1]за допомогою допоміжної функції prod, але, звичайно, це product [1..n]було б простіше. Я можу лише припустити, що вони не зробили цього жорстким у своєму другому аргументі на тій підставі, що це те, що ghc дуже впевнено може скласти до простого накопичувача.
AndrewC

Відповіді:


168

Хаскелл використовує метод ледачого оцінювання для реалізації рекурсії, тому розглядає будь-що як обіцянку надати значення, коли це потрібно (це називається глупотою). Думки зменшуються лише настільки, наскільки це необхідно для продовження, не більше. Це нагадує спосіб математичного спрощення виразу, тому корисно думати про це так. Той факт, що порядок оцінки не вказаний вашим кодом, дозволяє компілятору зробити ще більше розумних оптимізацій, ніж просто усунення хвостового виклику, до якого ви звикли. Компілюйте, -O2якщо хочете оптимізацію!

Давайте подивимося, як ми оцінюємо facSlow 5як тематичне дослідження:

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

Точно так само, як ви хвилюєтесь, у нас є накопичення чисел до будь-яких обчислень, але на відміну від вас хвилюється, у вас немає стека facSlowвикликів функцій, які чекають завершення - кожне зменшення застосовується і зникає, залишаючи кадр стека в своєму wake (це тому, що (*)є суворим і тому викликає оцінку його другого аргументу).

Рекурсивні функції Хаскелла оцінюються не дуже рекурсивно! Єдиний стос дзвінків, що зависли, - це самі множення. Якщо (*) розглядається як строгий конструктор даних, це те, що називається захищеною рекурсією (хоча її зазвичай називають такою з не конструкторами даних -strict, де то , що залишилося на своєму шляху є конструктори даних - при вимушеному шляху подальшого доступу).

А тепер давайте розглянемо хвостову рекурсиву fac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

Тож ви бачите, як рекурсія хвоста сама по собі не заощадила вам ні часу, ні місця. Це не тільки робить більше кроків загалом, ніжfacSlow 5 він , а й створює вкладений тунк (показано тут як {...}) - для цього потрібен додатковий простір - що описує майбутні обчислення, вкладені множення, які потрібно виконати.

Потім цей хит розгадується шляхом обходу його до дна, відтворюючи обчислення в стеку. Тут також існує небезпека спричинення переповнення стека з дуже довгими обчисленнями для обох версій.

Якщо ми хочемо оптимізувати це вручну, все, що нам потрібно зробити, це зробити це суворим. Ви можете використовувати строгий оператор програми$! для визначення

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

Це змушує facS' бути суворим у своєму другому аргументі. (Це вже суворо в першому аргументі, оскільки це має бути оцінено, щоб вирішити, яке визначення facS'застосовувати.)

Іноді строгість може надзвичайно допомогти, іноді це велика помилка, тому що лінощі ефективніші. Ось це гарна ідея:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

Я саме цього і хотів досягти, я думаю.

Резюме

  • Якщо ви хочете оптимізувати свій код, першим кроком є ​​компіляція з -O2
  • Рекурсія хвоста хороша лише тоді, коли немає накопичення, а додавання строгості зазвичай допомагає запобігти цьому, якщо і де це доречно. Це трапляється, коли ви будуєте результат, який потрібен згодом, відразу.
  • Іноді рекурсія хвоста - це поганий план, а рекурсія, що охороняється, краще підходить, тобто коли результат, який ви будуєте, буде потроху потрібен, по частинах. Дивіться це питання про foldrтаfoldl наприклад, і протестуйте їх один проти одного.

Спробуйте ці два:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1є рекурсивним хвостом, тоді як foldr1виконує захищену рекурсію, щоб перший елемент був негайно представлений для подальшої обробки / доступу. (Перша "дужки" відразу вліво (...((s+s)+s)+...)+s, вимушуючи свій список введення повністю до кінця і будуючи велику думку майбутнього обчислення набагато швидше, ніж потрібні його повні результати; друга дужка поступово s+(s+(...+(s+s)...)), споживаючи введені дані перераховуйте поступово, тому все це може працювати в постійному просторі з оптимізацією).

Можливо, вам доведеться відрегулювати кількість нулів залежно від того, яке обладнання ви використовуєте.


1
@WillNess Це чудово, дякую. немає необхідності в втягуванні. Я думаю, що це зараз краща відповідь для нащадків.
AndrewC

4
Це чудово, але чи можу я запропонувати кивок на аналіз строгості ? Я думаю, що це майже напевно зробить роботу з рекурсивним факторіалом у будь-якій досить недавній версії GHC.
dfeuer

16

Слід зазначити, що facфункція не є добрим кандидатом для охоронної рекурсії. Рекурсія хвоста - це шлях сюди. Через лінощі ви не отримуєте ефекту TCO у своїй fac'функції, оскільки аргументи накопичувача продовжують нарощувати великі хитрощі, що при оцінці вимагатиме величезного стека. Щоб запобігти цьому та отримати бажаний ефект від TCO, вам потрібно зробити ці аргументи акумулятора суворими.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

Якщо ви компілюєте за допомогою -O2(або просто -O) GHC, можливо, зробите це самостійно на етапі аналізу строгості .


4
Я думаю, що це зрозуміліше, $!ніж з BangPatterns, але це хороша відповідь. Особливо згадка про аналіз строгості.
singpolyma

7

Вам слід переглянути статтю wiki про рекурсію хвоста в Haskell . Зокрема, з - за оцінки вираження, вид рекурсії ви хочете це охороняється рекурсією. Якщо ви опрацюєте деталі того, що відбувається під капотом (в абстрактній машині для Haskell), ви отримаєте те саме, що і з рекурсією хвоста строгими мовами. Поряд з цим у вас єдиний синтаксис для ледачих функцій (рекурсія хвоста прив’яже вас до суворого оцінювання, тоді як захищена рекурсія працює більш природно).

(І під час вивчення Хаскелла решта цих вікі-сторінок теж чудові!)


0

Якщо я правильно пам’ятаю, GHC автоматично оптимізує звичайні рекурсивні функції в оптимізовані хвостово-рекурсивні.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.