Коли немає TCO, коли турбуватися про видув стека?


14

Кожен раз, коли виникає дискусія про нову мову програмування, спрямовану на JVM, неминуче люди говорять такі речі, як:

"JVM не підтримує оптимізацію зворотного виклику, тому я прогнозую багато вибухаючих стеків"

На цю тему є тисячі варіацій.

Тепер я знаю, що деякі мови, як, наприклад, Clojure, мають особливу конструкцію повторень, яку ви можете використовувати.

Я не розумію: наскільки серйозною є відсутність оптимізації хвостових викликів? Коли я повинен турбуватися про це?

Моє основне джерело плутанини, ймовірно, пов'язане з тим, що Java - одна з найуспішніших мов коли-небудь, і досить багато мов JVM, здається, працюють досить добре. Як це можливо , якщо відсутність TCO дійсно з будь заклопотаності?


4
якщо у вас є рекурсія досить глибока, щоб підірвати стек без TCO, тоді у вас виникнуть проблеми навіть з TCO
храповиком виродком

18
@ratchet_freak Це нісенітниця. У схемі навіть немає циклів, але, оскільки специфікація вимагає підтримки TCO, рекурсивна ітерація над великим набором дат є не дорожчою, ніж імперативний цикл (з бонусом, який конструкція Scheme повертає значення).
йогобрюс

6
@ratchetfreak TCO - це механізм, завдяки якому рекурсивні функції, записані певним чином (тобто рекурсивно хвостиком), не можуть повністю збити стік, навіть якщо вони цього хочуть. Ваша заява має сенс лише для рекурсії, яка не записується рекурсивно, в такому випадку ви правильні, і TCO не допоможе вам.
Евікатос

2
Останнє, що я подивився, 80x86 також не робить (рідної) хвостової оптимізації. Але це не зупинило розробників мови від перенесення мов, які ним користуються. Компілятор визначає, коли він може використовувати стрибок проти jsr, і кожен задоволений. Можна зробити те ж саме на JVM.
kdgregory

3
@kdgregory: Але x86 має GOTO, JVM - ні. І x86 не використовується як платформа interop. У JVM немає GOTOі однією з основних причин вибору платформи Java є інтероп. Якщо ви хочете реалізувати TCO на JVM, вам доведеться щось зробити з стеком. Керуйте ним самостійно (тобто взагалі не використовуйте стек викликів JVM), використовуйте батути, використовуйте винятки як GOTOщось подібне. У всіх цих випадках ви стаєте несумісними зі стеком дзвінків JVM. Неможливо бути сумісним з стеком з Java, мати TCO та високу продуктивність. Ви повинні пожертвувати одним із цих трьох.
Йорг W Міттаг

Відповіді:


16

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

int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
  if(i == 0) return accum;
  return factorial(i-1, accum * i);
}

Зараз ми відчуваємо себе досить розумними, нам вдалося написати наш фабрику навіть без циклів! Але коли ми тестуємо, ми помічаємо, що з будь-яким розумним розміром, ми отримуємо помилки стаціонарного потоку, оскільки немає TCO.

У реальній Java це не проблема. Якщо у нас коли-небудь є хвостовий рекурсивний алгоритм, ми можемо перетворити його на цикл і бути просто чудовим. Однак як бути з мовами без циклів? Тоді ви просто шлангували. Ось чому clojure має таку recurформу, без неї вона навіть не закінчена (Немає способу робити нескінченні петлі).

Клас функціональних мов, орієнтованих на JVM, Frege, Kawa (Схема), Clojure, завжди намагаються боротися з відсутністю хвостових викликів, адже в цих мовах TC - це ідіоматичний спосіб робити петлі! Якщо перевести на схему, ця факторна частина була б гарною факторією. Було б жахливо незручно, якби циклічна робота 5000 разів зробила програму. Це можна вирішити, хоча за допомогою recurспеціальних форм, анотацій, що натякають на оптимізацію самодзвінків, батут, що завгодно. Але всі вони примушують або хіти до виконання, або непотрібну роботу над програмістом.

Тепер Java також не виходить безкоштовним, оскільки TCO більше, ніж просто рекурсія, що з взаємно рекурсивними функціями? Вони не можуть бути просто переведені в петлі, але все ще не оптимізовані JVM. Це робить надзвичайно неприємним спробувати писати алгоритми, використовуючи взаємну рекурсію за допомогою Java, оскільки, якщо ви хочете гідної продуктивності / діапазону, вам потрібно зробити темну магію, щоб змусити її вписатися в петлі.

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

return foo(bar, baz); // foo is just a simple method

або є рекурсією. Однак для класу ТС, які не вписуються в це, кожна мова JVM відчуває біль.

Однак є гідна причина, чому ми ще не маємо TCO. JVM дає нам сліди стека. За допомогою TCO ми систематично усуваємо стекфрейми, які, як ми знаємо, "приречені", але JVM насправді може захотіти їх пізніше для стеження! Скажімо, ми реалізуємо FSM так, де кожен стан хвоста викликає наступний. Ми видалили б усі записи попередніх станів, щоб відстеження показувало нам, у якому стані, але не про те, як ми туди потрапили.

Крім того, і ще більш напористим, велика частина перевірки байт-коду заснована на стеці, що виключає те, що дозволяє нам перевірити, що байт-код не є приємною перспективою. Між цим і тим, що у Java є петлі, TCO виглядає дещо більшим клопотом, ніж це варто інженерам JVM.


2
Найбільша проблема - це перевірка байтового коду, яка повністю заснована на огляді стека. Це основна помилка в специфікації JVM. 25 років тому, коли був спроектований JVM, люди вже говорили, що краще було б мати код байтового коду JVM, щоб бути безпечним в першу чергу, а не мати цю мову небезпечно, а потім покладатися на перевірку байтового коду після факту. Однак Маттіас Феллейзен (одна з головних фігур у спільноті Scheme) написав документ, в якому продемонстрував, як хвостові дзвінки можна додавати до JVM, зберігаючи перевірку коду байтів.
Йорг W Міттаг

2
Цікаво, що J9 JVM від IBM справді виконує TCO.
Йорг W Міттаг

1
@jozefg Цікаво, що ніхто не піклується про записи стек-ланцюгів для циклів, отже, аргумент stacktrace не містить води, принаймні для хвостових рекурсивних функцій.
Інго

2
@MasonWheeler Це якраз моя думка: стек-трек не говорить вам, в якій ітерації це відбулося. Ви можете це бачити лише опосередковано, перевіряючи змінні циклу і т. Д. То чому б вам хотілося кілька записів слідів hundert стека хвостової рекурсивної функції? Тільки останній цікавий! І, подібно до циклів, ви можете визначити, яка саме рекурсія була, перевіривши місцеві параметри, значення аргументів тощо
Ingo

3
@Ingo: Якщо функція повторюється лише сама, слід стека може виявитися не так багато. Якщо, однак, група функцій мутаційно рекурсивна, то сліди стека іноді можуть показувати багато.
supercat

7

Оптимізація хвостових викликів важлива головним чином через рекурсію хвоста. Однак є аргумент, чому насправді добре, що JVM не оптимізує хвостові виклики: Оскільки TCO повторно використовує частину стеку, слід стека від винятку буде неповним, тим самим зробивши налагодження трохи складніше.

Існують способи подолати обмеження JVM:

  1. Проста хвоста рекурсія може бути оптимізована компілятором до циклу.
  2. Якщо програма виконана у стилі продовження, то неприємно використовувати «батут». Тут функція не повертає кінцевий результат, а продовження, яке потім виконується зовні. Цей метод дозволяє автору компілятора моделювати довільно складний потік управління.

Для цього може знадобитися більший приклад. Розглянемо мову із закриттями (наприклад, JavaScript або подібну). Ми можемо написати фактор як

def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)

print fac(x)

Тепер ми можемо повернути йому зворотний виклик:

def fac(n, acc = 1) =
  if (n <= 1) acc
  else        (() => fac(n-1, acc*n))  // this isn't full CPS, but you get the idea…

var continuation = (() => fac(x))
while (continuation instanceof function) {
  continuation = continuation()
}
var result = continuation
print result

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

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

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


1
"Оскільки TCO повторно використовує частину стека, слід стека від винятку буде неповним", - так, але тоді, стек-трек із циклу є неповним - він не записує, як часто цикл виконувався. - На жаль, навіть якщо JVM підтримує належні виклики хвоста, все одно можна відмовитися під час налагодження, скажімо. А потім, для виробництва, дозвольте TCO бути впевненим, що код працює зі 100 000 або 100 000 000 хвостових викликів.
Інго

1
@ Ingo No. (1) Якщо петлі не реалізовані як рекурсія, немає обґрунтування їх відображення на стеці (хвостовий дзвінок ≠ стрибок ≠ виклик). (2) ТСО більш загальна, ніж оптимізація хвостової рекурсії. Моя відповідь використовує рекурсію як приклад . (3) Якщо ви програмуєте у стилі, що спирається на TCO, вимкнути цю оптимізацію не є варіантом - повний TCO або повний стек стека є мовною особливістю, або вони не є. Наприклад, схема вдається збалансувати недоліки TCO за допомогою більш досконалої системи виключень.
амон

1
(1) повністю згоден. Але тим самим міркуванням, звичайно, немає обґрунтування, щоб зберегти сотні і тисячі записів слідів стека, на які всі вказують return foo(....);у методі foo(2), повністю згодні. Тим не менш, ми приймаємо неповне трасування з циклів, призначень (!), Послідовностей операторів. Наприклад, якщо ви знайдете несподіване значення в змінній, ви неодмінно хочете дізнатися, як вона потрапила туди. Але ви не скаржитеся на відсутність слідів у цій справі. Тому що це так чи інакше вписане в наш мозок, що: а) це відбувається лише на дзвінках; б) це відбувається на всіх дзвінках. І те й інше не має сенсу, ІМХО.
Інго

(3) Не згоден. Я не бачу жодної причини, чому не можна буде налагоджувати код з проблемою розміру N, для деяких N, достатньо малих, щоб відійти від звичайного стека. А потім, увімкнути перемикач і включити TCO - ефективно скидає обмеження на розмір задачі.
Інго

@Ingo "Не погоджуюсь. Я не бачу жодної причини, чому не можна буде налагоджувати код з проблемою розміру N, для деяких N, достатньо малих, щоб відійти від звичайного стека. " Якщо TCO / TCE призначений для перетворення CPS, то його вимкнення переповнюватиме стек та завершує роботу програми, тому налагодження не буде можливим. Google відмовився впроваджувати TCO у V8 JS через те, що ця проблема виникала випадково . Вони хотіли б отримати якийсь особливий синтаксис, щоб програміст міг заявити, що він справді хоче TCO та втрату сліду стека. Хтось знає, чи винятки також накручує ТСО?
Шелбі Мур III

6

Значну частину дзвінків у програмі становлять хвостові дзвінки. Кожна підпрограма має останній виклик, тому кожна підпрограма має щонайменше один виклик хвоста. Хвостові дзвінки мають характеристики продуктивності, GOTOале безпеку виклику підпрограми.

Правильні дзвінки на хвості дозволяють писати програми, які ви не можете написати. Візьмемо, наприклад, державну машину. Машина стану може бути дуже безпосередньо реалізована, якщо кожний стан є підпрограмою, а кожен стан переходу є викликом підпрограми. У такому випадку ви переходите з стану в стан до стану, здійснюючи дзвінок після дзвінка після дзвінка, і ви фактично ніколи не повертаєтесь! Без належних викликів хвоста, ви негайно підірвали стек.

Без PTC ви повинні використовувати GOTOTrampolines або винятки як контрольний потік або щось подібне. Це набагато хижіше, і не стільки пряме представлення державної машини 1: 1.

(Зверніть увагу, як я розумно уникав використовувати нудний приклад "циклу". Це приклад, коли PTC корисні навіть мовою з петлями.)

Я навмисно вжив тут термін "належні дзвінки" замість TCO. TCO - це оптимізація компілятора. PTC - це мовна функція, яка вимагає від кожного компілятора виконувати TCO.


The vast majority of calls in a program are tail calls. Не в тому випадку, якщо "переважна більшість" названих методів виконують більше, ніж один власний виклик. Every subroutine has a last call, so every subroutine has at least one tail call. Це тривіально доказово як брехня: return a + b. (Якщо ви не є якоюсь божевільною мовою, де основні арифметичні операції визначені як виклики функцій, звичайно.)
Мейсон Уілер

1
"Додавання двох чисел - це додавання двох чисел." За винятком мов, де його немає. А як щодо операції + у Lisp / Scheme, коли один арифметичний оператор може приймати довільну кількість аргументів? (+ 1 2 3) Єдиний розумний спосіб реалізувати це як функцію.
Евікатос

1
@ Мейсон Уілер: Що ви розумієте під інверсією абстракції?
Джорджіо

1
@MasonWheeler Це, без сумніву, самий хвилястий запис Вікіпедії з технічної тематики, який я коли-небудь бачив. Я бачив деякі сумнівні записи, але це просто ... ух.
Евікатос

1
@MasonWheeler: Ви говорите про функції довжини списку на сторінках 22 та 23 програми On Lisp? Хвостова версія дзвінка приблизно 1,2х настільки складна, ніде близько 3х. Мені також незрозуміло, що ви маєте на увазі під інверсією абстракції.
Майкл Шоу

4

"JVM не підтримує оптимізацію зворотного виклику, тому я прогнозую багато вибухаючих стеків"

Той, хто говорить це (1), не розуміє оптимізацію хвостових викликів, або (2) не розуміє JVM, або (3) обидва.

Почну з визначення хвостових дзвінків з Вікіпедії (якщо вам не подобається Вікіпедія, ось альтернатива ):

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

У наведеному нижче коді заклик до bar()хвостового виклику foo():

private void foo() {
    // do something
    bar()
}

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

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

public static int fact(int n) {
    if (n <= 1) return 1;
    else return n * fact(n - 1);
}

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

  • Платформа, яка підтримує розгалуження, крім викликів підпрограми.
  • Статичний аналізатор, який може визначити, чи можлива оптимізація виклику хвоста.

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

Елемент статичного аналізу - це те, що хитро. У межах однієї функції це не проблема. Наприклад, ось рекурсивна функція Scala для підсумовування значень у List:

def sum(acc:Int, list:List[Int]) : Int = {
  if (list.isEmpty) acc
  else sum(acc + list.head, list.tail)
}

Ця функція перетворюється на наступний байт-код:

public int sum(int, scala.collection.immutable.List);
  Code:
   0:   aload_2
   1:   invokevirtual   #63; //Method scala/collection/immutable/List.isEmpty:()Z
   4:   ifeq    9
   7:   iload_1
   8:   ireturn
   9:   iload_1
   10:  aload_2
   11:  invokevirtual   #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
   14:  invokestatic    #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
   17:  iadd
   18:  aload_2
   19:  invokevirtual   #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
   22:  checkcast   #59; //class scala/collection/immutable/List
   25:  astore_2
   26:  istore_1
   27:  goto    0

Зверніть увагу goto 0на кінець. Для порівняння, еквівалентна функція Java (яка повинна використовувати a Iteratorдля імітації поведінки розбиття списку Scala на голову та хвіст) перетворюється на наступний байт-код. Зверніть увагу , що останні дві операції тепер Invoke , а потім явним поверненням значення , отримане з допомогою цього рекурсивного виклику.

public static int sum(int, java.util.Iterator);
  Code:
   0:   aload_1
   1:   invokeinterface #64,  1; //InterfaceMethod java/util/Iterator.hasNext:()Z
   6:   ifne    11
   9:   iload_0
   10:  ireturn
   11:  iload_0
   12:  aload_1
   13:  invokeinterface #70,  1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
   18:  checkcast   #25; //class java/lang/Integer
   21:  invokevirtual   #74; //Method java/lang/Integer.intValue:()I
   24:  iadd
   25:  aload_1
   26:  invokestatic    #43; //Method sum:(ILjava/util/Iterator;)I
   29:  ireturn

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

Там, де життя стає складним, якщо у вас є кілька методів. Інструкції з розгалуження JVM, на відміну від процесорів загального призначення, таких як 80x86, обмежуються одним методом. Це все ще досить просто, якщо у вас є приватні методи: компілятор вільний вкладати ці методи за необхідності, тому може оптимізувати хвостові виклики (якщо вам цікаво, як це може працювати, розгляньте загальний метод, який використовує a switchдля керування поведінкою). Ви навіть можете поширити цю методику на кілька публічних методів в одному класі: компілятор накреслює органи методів, надає загальнодоступні методи мосту, а внутрішні виклики перетворюються на стрибки.

Але ця модель руйнується, коли ви розглядаєте публічні методи в різних класах, особливо з огляду на інтерфейси та навантажувачі класів. Компілятор рівня джерела просто не має достатньо знань для впровадження оптимізації хвостових викликів. Однак, на відміну від реалізацій «голого металу», * JVM (має інформацію для цього у формі компілятора Hotspot (принаймні, колишній компілятор Sun). Я не знаю, чи реально він працює оптимізація хвістних дзвінків, і підозрюваного немає, але це могло б .

Що підводить мене до другої частини Вашого запитання, яку я перефразую як "нам слід хвилюватись?"

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

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

Що стосується коду, який можна оптимізувати при виклику хвоста, часто перевести цей код в ітерабельну форму. Наприклад, та sum()функція, яку я показав раніше, може бути узагальнена як foldLeft(). Якщо ви подивитесь на джерело , то побачите, що воно насправді реалізується як ітеративна операція. Йорг W Міттаг мав приклад стану машини, реалізованої за допомогою викликів функцій; існує безліч ефективних (і ремонтоздатних) державних машинних реалізацій, які не покладаються на переклики функцій, що переводяться у стрибки.

Я закінчу щось зовсім інше. Якщо ви перейдете до виносок у SICP, ви можете опинитися тут . Я особисто вважаю, що це набагато цікавіше місце, ніж замінити мого компілятора JSRна JUMP.


Якщо існує опкод хвостового виклику, чому оптимізація хвостового дзвінка вимагає нічого іншого, крім спостереження на кожному сайті виклику, чи не повинен метод, який здійснює виклик, після цього виконувати будь-який код? Може статися, що в деяких випадках оператор на зразок return foo(123);може бути краще виконаний вкладишем, fooніж шляхом генерування коду для маніпулювання стеком та виконання стрибка, але я не бачу, чому хвіст-виклик відрізнятиметься від звичайного виклику в з цього приводу.
supercat

@supercat - Я не впевнений, у чому ваше запитання. Перший пункт цієї публікації полягає в тому, що компілятор не може знати, як може виглядати кадр стека всіх потенційних викликів (пам’ятайте, що кадр стека містить не лише аргументи функції, а й його локальні змінні). Я припускаю, що ви можете додати опкод, який виконує перевірку сумісності кадрів, але це приводить мене до другої частини публікації: яка реальна цінність?
kdgregory
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.