Яка властивість мінусів дозволяє усунути хвостові модулі рекурсії хвоста?


14

Мені знайома ідея усунення основної хвостової рекурсії, де функції, які повертають собі прямий результат дзвінка, можуть бути переписані у вигляді ітеративних циклів.

foo(...):
    # ...
    return foo(...)

Я також розумію, що, як особливий випадок, функцію все одно можна переписати, якщо рекурсивний виклик буде завернуто у виклик cons.

foo(...):
    # ...
    return (..., foo(...))

Яка властивість цього consдозволяє? Які інші функції, крім того, що consможуть обернути рекурсивний дзвінок хвоста, не руйнуючи нашу здатність повторно його переписувати?

GCC (але не Clang) здатний оптимізувати цей приклад " розмноження модуля рекурсійної хвороби ", але незрозуміло, який механізм дозволяє виявити це або як він здійснює свої перетворення.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
У посиланні провідника компілятора Godbolt ваша функція є if(n==0) return 0;(не повертайте 1, як у вашому запитанні). x^0 = 1, то це помилка. Не те, що для решти питання це важливо; ітеративний asm спочатку перевіряє цей особливий випадок. Але як не дивно, ітеративна реалізація вводить множину того, 1 * xщо не було в джерелі, навіть якщо ми робимо floatверсію. gcc.godbolt.org/z/eqwine (і gcc досягає успіху лише -ffast-math)
Пітер Кордес

@PeterCordes Хороший улов. Це return 0було виправлено. Цікаве множення на 1. Я не впевнений, що з цього зробити.
Maxpm

Я думаю, що це побічний ефект від того, як GCC перетворюється, перетворюючи його в цикл. Очевидно, що gcc має деякі пропущені оптимізації, наприклад, пропускає його floatбез -ffast-math, хоча це значення має щоразу множитися. (За винятком 1.0f`, який може стати точкою перешкод?)
Пітер Кордес

Відповіді:


12

Хоча GCC, ймовірно, використовує спеціальні правила, ви можете отримати їх наступним чином. Я буду використовувати powдля ілюстрації, оскільки ви fooнастільки розпливчасто визначені. Крім того, це fooможе найкраще розумітися як екземпляр оптимізації останнього дзвінка стосовно змінних з одним призначенням, якими володіє мова Oz та як обговорюється в Концепціях, методах та моделях комп'ютерного програмування . Перевага використання змінних з одним призначенням полягає в тому, що вона дозволяє залишатися в межах декларативної парадигми програмування. По суті, ви можете мати кожне поле fooвіддачі структури, представлене змінними одного призначення, які потім передаються fooяк додаткові аргументи. fooпотім стає хвостовим-рекурсивнимvoidфункція повернення. Для цього не потрібна особлива кмітливість.

Повертаючись до pow, по-перше, перетворіть у стиль проходження продовження . powстає:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

Усі дзвінки зараз є хвостиками. Однак контрольний стек переміщений у захоплені середовища в замиканнях, що представляють собою продовження.

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

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

Що applyPow(k, acc)потрібно зробити, це взяти список, тобто безкоштовний моноїд, як k=Cons(x, Cons(x, Cons(x, Nil)))і зробити його x*(x*(x*acc)). Але оскільки *є асоціативним і, як правило, утворює моноїд з одиницею 1, ми можемо поєднати це ((x*x)*x)*accі, для простоти, 1взяти на себе початок, породжуючи (((1*x)*x)*x)*acc. Ключовим є те, що ми можемо частково обчислити результат ще до того, як маємо acc. Це означає, що замість того, щоб переходити kнавколо як список, який по суті є деяким неповним «синтаксисом», який ми «інтерпретуємо» наприкінці, ми можемо «інтерпретувати» його так, як ми йдемо. Підсумок полягає в тому, що ми можемо замінити Nilодиницю моноїда, 1в даному випадку і Consфункціонуванням моноїда *, і тепер kпредставляє "працюючий продукт".applyPow(k, acc)тоді стає просто тим, до k*accчого ми можемо повернутисьpow2 та спростити виробництво:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

Версія оригінального стилю, що передає акумулятор, хвостово-рекурсивна pow .

Звичайно, я не кажу, що GCC все це міркує під час компіляції. Я не знаю, яку логіку використовує GCC. Моя думка, просто зробивши це міркування один раз, відносно легко розпізнати шаблон і негайно перевести вихідний вихідний код у цю остаточну форму. Однак трансформація CPS та дефункціоналізація є повністю загальними та механічними. Звідси методи злиття, вирубки лісів або суперкомпіляції можуть бути використані для спроби усунути змінені продовження. Спекулятивні перетворення можуть бути відкинуті, якщо неможливо усунути всі виділення змінених продовжень. Я підозрюю, що це буде занадто дорого робити весь час, у повній загальності, отже, більше спеціальних підходів.

Якщо ви хочете бути смішними, ви можете ознайомитись з документом Recycling Continuations, який також використовує CPS та уявлення про продовження як дані, але робить щось подібне, але відмінне від хвостової рекурсії-модуль-мінусів. Тут описано, як можна створювати алгоритми реверсування покажчиків шляхом перетворення.

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


Метод, який GCC використовує замість стилю проходження продовження, який ви показуєте тут, я вважаю, статичною формою єдиного призначення.
Девіслор

@Davislor Незважаючи на те, що стосується CPS, SSA не впливає на контрольний потік процедури, а також не переспрямовує стек (або іншим чином вводить структури даних, які потрібно динамічно розподіляти). Що стосується SSA, CPS "робить занадто багато", тому адміністративна нормальна форма (ANF) ближче до SSA. Таким чином, GCC використовує SSA, але SSA не призводить до того, що стек управління може переглядатись як керована структура даних.
Дерек Елкінс покинув SE

Правильно. Я відповідав: «Я не кажу, що GCC робить все це міркування під час компіляції. Я не знаю, яку логіку використовує GCC ». Моя відповідь так само показала, що перетворення теоретично виправдане, не кажучи про те, що це метод реалізації, який використовує будь-який компілятор. (Хоча, як відомо, багато компіляторів перетворюють програму на CPS під час оптимізації.)
Девіслор

8

Я буду деякий час бити навколо куща, але є сенс.

Напівгрупи

Відповідь - асоціативна властивість операції бінарного скорочення .

Це досить абстрактно, але множення - хороший приклад. Якщо x , y і z - це натуральні числа (або цілі числа, або раціональні числа, або дійсні числа, або складні числа, або матриці N × N , або будь-яка ціла купа більше речей), то x × y - це той самий вид числа як x, так і y . Ми почали з двох чисел, тож це двійкова операція, і отримали одну, тому ми зменшили кількість чисел, які ми мали на одиницю, зробивши це операцією скорочення. І ( x × y ) × z завжди те саме, що x × ( y ×) z), що є асоціативною властивістю.

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

Ще кілька речей, які ви часто бачите в інформатиці, які працюють однаково:

  • додавання будь-якого з цих чисел, а не множення
  • конкатенації рядків ( "a"+"b"+"c"це "abc"почати чи з "ab"+"c"або"a"+"bc" )
  • Склеювання двох списків разом. [a]++[b]++[c]аналогічно [a,b,c]або ззаду спереду, або спереду назад.
  • consна голову та хвіст, якщо ви думаєте про голову як про однотонний список. Це просто об'єднання двох списків.
  • прийняття союзу або перетину множин
  • Булева і, булева або
  • розрядно &, |і^
  • склад функцій: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( h ( x )))
  • максимум і мінімум
  • додавання модуля p

Деякі речі, які не:

  • віднімання, тому що 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), тому що tan (π / 4 + π / 4) не визначений
  • множення на від’ємні числа, тому що -1 × -1 не є від’ємним числом
  • ділення цілих чисел, яке має всі три проблеми!
  • логічно ні, оскільки він має лише один операнд, а не два
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, як print2( print2(x,y), z );і print2( x, print2(y,z) );мають різний вихід.

Це досить корисна концепція, що ми її назвали. Набір з операцією, яка має ці властивості, є напівгрупою . Отже, реальні числа при множенні є напівгрупою. І ваше запитання виявляється одним із способів такого роду абстрагування стати корисним у реальному світі. Операції напівгрупи можна оптимізувати так, як ви просили.

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

Наскільки мені відомо, ця методика була вперше описана в 1974 році в роботі Даніеля Фрідмана та Девіда Уайза «Складання стилізованих рекурсій в ітерації». , хоча вони припускали ще кілька властивостей, ніж виявляється, що їм потрібно.

Haskell є чудовою мовою для того, щоб проілюструвати це, оскільки він має Semigroupклас класів у своїй стандартній бібліотеці. Він називає роботу загального Semigroupоператора <>. Оскільки списки та рядки є екземплярами, наприклад Semigroup, їх екземпляри визначають <>як оператор конкатенації ++, наприклад. І при правильному імпорті [a] <> [b]є псевдонімом [a] ++ [b], який є[a,b] .

Але, як щодо цифр? Ми тільки що бачили , що числові типи є напівгрупами під або того чи множення! Так хто добирається , щоб бути <>для Double? Ну, або один! Haskell визначає типи Product Double, where (<>) = (*)(тобто фактичне визначення в Haskell), а також Sum Double,where (<>) = (+) .

Одна зморшка полягає в тому, що ви використали той факт, що 1 - це мультиплікативна ідентичність. Напівгрупа з ідентичністю називається моноїдом і визначається в пакеті Haskell Data.Monoid, який називає загальний елемент ідентичності типу класу mempty. Sum, ProductІ кожен з них має список одиничний елемент (0, 1 і [], відповідно), так що вони є екземплярами Monoid, а також Semigroup. (Не плутати з монадою , тому просто забудь, я навіть виховував їх.)

Цього достатньо інформації, щоб перевести алгоритм у функцію Haskell за допомогою моноїдів:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

Важливо відзначити, що це модульна напівгрупа рекурсії хвоста: кожен випадок є або значенням, хвостово-рекурсивним викликом, або продуктом напівгрупи обох. Також цей приклад трапився memptyдля одного із випадків, але якби це нам не було потрібно, ми могли б зробити це з більш загальним класом Semigroup.

Давайте завантажимо цю програму в GHCI і подивимося, як вона працює:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Пам’ятаєте, як ми оголосили powдля родового Monoid, чий тип ми назвали a? Ми дали GHCI достатньо інформації , щоб зробити висновок , що тип aтут Product Integer, який є instanceз Monoidякого <>операція є цілочисельне множення. Так pow 2 4розширюється рекурсивно до того 2<>2<>2<>2, що є 2*2*2*2або 16. Все йде нормально.

Але наша функція використовує лише загальні моноїдні операції. Раніше я говорив, що є ще один екземплярMonoid виклику Sum, чия <>операція є +. Чи можемо ми спробувати це?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

Те саме розширення дає нам 2+2+2+2замість цього2*2*2*2 . Множення - це додавання, оскільки експоненція - це множення!

Але я навев ще один приклад моноїда Хаскелла: списки, операція яких є конкатенацією.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

Написання [2]повідомляє компілятору, що це список, <>у списках є++ , так [2]++[2]++[2]++[2]це[2,2,2,2] .

Нарешті, алгоритм (два, фактично)

Просто замінивши xна [x], ви перетворюєте загальний алгоритм, який використовує модуль рекурсії, напівгрупу в такий, який створює список. Який список? Перелік елементів, які застосовує алгоритм<> . Оскільки ми використовували лише операції напівгрупи, у яких також є списки, отриманий список буде ізоморфним для початкових обчислень. А оскільки оригінальна операція була асоціативною, ми можемо однаково добре оцінити елементи спереду або спереду назад.

Якщо ваш алгоритм коли-небудь досягне базового випадку і припиняється, список буде порожнім. Оскільки термінальний випадок повернув щось, це буде завершальним елементом списку, тому він матиме принаймні один елемент.

Як ви застосовуєте порядок бінарного скорочення до кожного елемента списку в порядку? Правильно, складка. Таким чином , ви можете замінити [x]на x, отримати список елементів , щоб скоротити <>, а потім або правої згин або лівий складіть список:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

Версія з foldr1фактично існує в стандартній бібліотеці, так як sconcatдля Semigroupі mconcatдля Monoid. Це ледачий правий склад у списку. Тобто вона розширюється [Product 2,Product 2,Product 2,Product 2]до 2<>(2<>(2<>(2))).

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

Версія з foldl1'- це суворо оцінена ліва складка. Тобто, хвостово-рекурсивна функція із суворим акумулятором. Це оцінюється до (((2)<>2)<>2)<>2, обчислюється негайно, а не пізніше, коли це потрібно. (Принаймні, у самій складці немає затримок. Складений список генерується тут іншою функцією, яка може містити ледачу оцінку.) Отже, складка обчислює (4<>2)<>2, потім відразу обчислює 8<>2, потім16 . Ось чому нам потрібна була операція, щоб вона була асоціативною: ми просто змінили групування дужок!

Сувора ліва складка є еквівалентом того, що робить GCC. Найменше ліве число в попередньому прикладі - це акумулятор, в даному випадку працює продукт. На кожному кроці його множать на наступне число у списку. Ще один спосіб виразити це: ви повторюєте значення, які потрібно помножити, зберігаючи працюючий продукт у акумуляторі, і на кожній ітерації ви помножуєте акумулятор на наступне значення. Тобто це whileпетля в маскуванні.

Іноді це можна зробити так само ефективно. Компілятор, можливо, зможе оптимізувати структуру даних списку в пам'яті. Теоретично, вона має достатньо інформації під час компіляції, щоб зрозуміти, що вона повинна робити це тут: [x]синглтон, так [x]<>xsсамо як cons x xs. Кожна ітерація функції може мати можливість повторно використовувати один і той же кадр стека та оновити параметри на місці.

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

Отже, як ви бачите, можна автоматично оптимізувати модуль рекурсії хвоста будь-якої напівгрупи (одним із прикладів є будь-який із звичайних числових типів при множенні) до ледачої правої складки або суворої лівої складки в одному рядку Хаскелл.

Узагальнення далі

Два аргументи двійкової операції не повинні бути однотипними, доки початкове значення є тим самим типом, що і ваш результат. (Звичайно, ви завжди можете гортати аргументи, щоб відповідати порядку вибору складки, зліва чи справа.) Отже, ви можете неодноразово додавати патчі до файлу, щоб отримати оновлений файл, або починаючи з початкового значення 1,0, розділити на цілі числа, щоб накопичити результат з плаваючою комою. Або додайте елементи до порожнього списку, щоб отримати список.

Іншим типом узагальнення є застосування складок не до списків, а до інших Foldableструктур даних. Часто незмінний лінійний зв'язаний список не є структурою даних, яку потрібно для заданого алгоритму. Одне питання, про яке я не звертався вище, - це те, що набагато ефективніше додавати елементи в передню частину списку, ніж в задню, і коли операція не є комутативною, застосовуючи xліворуч і праворуч операції, це не той самий. Отже, вам потрібно буде використовувати іншу структуру, таку як пара списків або двійкове дерево, щоб представити алгоритм, який може застосовуватися xправоруч <>і ліворуч.

Також зауважте, що асоціативна властивість дозволяє перегрупувати операції іншими корисними способами, такими як поділ і перемога:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Або автоматичний паралелізм, коли кожен потік зменшує піддіапазон до значення, яке потім поєднується з іншими.


1
Ми можемо провести експеримент, щоб перевірити, що асоціативність є ключем до здатності GCC робити цю оптимізацію: pow(float x, unsigned n)версія gcc.godbolt.org/z/eqwine оптимізується лише за допомогою -ffast-math(що означає -fassociative-math. Сувора плаваюча точка, звичайно, не асоціативна, оскільки різні часові періоди = різні округлення). Введений a, 1.0f * xякий не був присутній в абстрактній машині C (але завжди дасть ідентичний результат). Тоді n-1 множення на зразок do{res*=x;}while(--n!=1)те саме, що і рекурсивне, тому це пропущена оптимізація.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.