Які обмеження встановлює JVM для оптимізації хвостових викликів


36

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

Компілятор Scala здатний виконувати TCO для рекурсивної функції, але не для двох взаємно рекурсивних функцій.

Щоразу, коли я читав про ці обмеження, вони завжди приписувалися деяким обмеженням, властивим моделі JVM. Я майже нічого не знаю про компілятори, але це трохи спантеличує мене. Дозвольте взяти приклад із Programming Scala. Тут функція

def approximate(guess: Double): Double =
  if (isGoodEnough(guess)) guess
  else approximate(improve(guess))

перекладається на

0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2

Отже, на рівні байт-коду просто потрібно goto. У цьому випадку насправді важку роботу виконує компілятор.

Яка функція базової віртуальної машини дозволила б компілятору легше обробляти TCO?

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

Відповіді:


25

Зараз я мало знаю про Клуджуре і мало про Скалу, але я спробую.

По-перше, нам потрібно розмежувати хвіст-CALLs та хвост-RECURSION. Хвостову рекурсію справді досить легко перетворити на петлю. З хвостовими дзвінками набагато складніше неможливо в загальному випадку. Вам потрібно знати, як викликається, але з поліморфізмом та / або першокласними функціями ви це рідко знаєте, тому компілятор не може знати, як замінити виклик. Тільки під час виконання ви знаєте цільовий код і можете перескочити туди, не виділяючи ще один кадр стека. Наприклад, наступний фрагмент має хвостовий виклик і при правильній оптимізації не потребує місця для стека (включаючи TCO), але він не може бути усунений під час компіляції для JVM:

function forward(obj: Callable<int, int>, arg: int) =
    let arg1 <- arg + 1 in obj.call(arg1)

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

Яка функція базової віртуальної машини дозволила б компілятору легше обробляти TCO?

Інструкція про виклик хвоста, як, наприклад, у Lua 5.1 VM. Ваш приклад не стає набагато простішим. Моє стає приблизно таким:

push arg
push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled

Як сторона, я б не очікував, що фактичні машини будуть набагато розумнішими, ніж JVM.

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


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

7
+1, tailcallінструкція для JVM вже була запропонована ще в 2007 році: Блог на sun.com через автомат зворотного шляху . Після поглинання Oracle це посилання 404-х. Я б здогадувався, що він не потрапив до списку пріоритетів JVM 7.
К.Штефф

1
tailcallІнструкція означатиме тільки хвіст виклик , як хвіст виклик. Чи реально оптимізований JVM згаданий хвіст - це зовсім інше питання. У CLI CIL є .tailпрефікс інструкції, але 64-бітний CLR Microsoft довгий час не оптимізував його. Ото, то IBM J9 JVM робить виявлення хвостових викликів і оптимізує їх, без необхідності спеціальної інструкції , щоб вказати, які виклики хвостових викликів. Анотування хвостових дзвінків та оптимізація хвостових дзвінків дійсно є ортогональними. (Окрім того, що статично виведення, який дзвінок є хвостовим дзвінком, може бути або не може бути невирішеним. Данно.)
Jörg W Mittag

@ JörgWMittag Ви зробите хорошу точку, JVM може легко виявити шаблон call something; oreturn. Основна робота JVM SPEC поновлення буде не вводити явне вказівку хвостового виклику але мандат , що таке розпорядження оптимізовано. Така інструкція лише полегшує завдання авторів компілятора: Автору JVM не потрібно обов'язково розпізнавати цю послідовність інструкцій, перш ніж вона буде невпізнана, і компілятор байтових кодів X-> може бути впевнений, що їхній байт-код недійсний або насправді оптимізована, ніколи не коректна, але переповнена стека.

@delnan: Послідовність call something; return;буде еквівалентною лише хвостовим викликом, якщо виклик, що викликається, ніколи не вимагає сліду стека; якщо розглянутий метод віртуальний або викликає віртуальний метод, JVM не зможе знати, чи може він запитати про стек.
supercat

12

Clojure міг би виконати автоматичну оптимізацію хвостової рекурсії в петлі: це, безумовно, можна зробити на JVM, як доводить Scala.

Це було насправді дизайнерським рішенням цього не робити - вам потрібно чітко використовувати recurспеціальну форму, якщо ви хочете цю функцію. Див. Поштовий потік Re: Чому не використовується оптимізація хвостових викликів у групі Google Clojure.

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

Майбутня ітерація JVM, ймовірно, отримає цю можливість, хоча, ймовірно, як варіант, щоб підтримувати зворотну сумісну поведінку для старого коду. Скажімо, попередній перегляд функцій у Geeknizer перелічує це для Java 9:

Додавання хвостових викликів та продовження ...

Звичайно, майбутні дорожні карти завжди можуть змінюватися.

Як виявляється, справа все одно не така вже й велика. За 2 роки кодування Clojure я ніколи не стикався з ситуацією, коли брак TCO був проблемою. Основними причинами цього є:

  • Ви вже можете отримати швидку рецидиву хвоста для 99% поширених випадків із recurабо петлею. Випадки взаємної рекурсії хвоста досить рідкісні у звичайному коді
  • Навіть коли вам потрібна взаємна рекурсія, часто глибина рекурсії є досить малою, що ви можете просто зробити це на стеці в будь-якому випадку без TCO. TCO - це лише "оптимізація".
  • У дуже рідкісних випадках, коли вам потрібна певна форма взаємної рецидивування взаємної рекурсії, існує багато інших альтернатив, які можуть досягти тієї ж мети: ліниві послідовності, батути тощо.

"майбутня ітерація" - Попередній перегляд функцій на Geeknizer говорить для Java 9: Додавання хвостових викликів та продовження - це все?
гнат

1
Так - це все. Звичайно, майбутні дорожні карти завжди можуть змінюватися ....
mikera

5

Як сторона, я б не очікував, що фактичні машини будуть набагато розумнішими, ніж JVM.

Справа не в тому, щоб бути розумнішим, а в тому, щоб бути іншим. До недавнього часу JVM розроблявся та оптимізувався виключно для однієї мови (очевидно, Java), яка має дуже сувору модель пам'яті та виклику.

Мало того, що не було ніяких gotoабо покажчиків, навіть не було ніякого способу викликати функцію 'голою' (що не було методом, визначеним у класі).

Концептуально, орієнтуючись на JVM, автор-компілятор повинен запитати "як я можу виразити це поняття в термінах Java?". І очевидно, немає можливості виразити TCO на Java.

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

Лише нещодавно влада Java почала серйозно сприймати JVM як платформу для мов, які не є Java, тому вона вже отримала певну підтримку функцій, які не мають еквівалента Java. Найвідомішим є динамічне введення тексту, яке вже є в JVM, але не в Java.


3

Отже, на рівні байт-коду просто потрібно goto. У цьому випадку насправді важку роботу виконує компілятор.

Ви помітили, що адреса методу починається з 0? Що всі методи наборів починаються з 0? JVM не дозволяє стрибнути за межі методу.

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

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


Влучне зауваження. Але для оптимізації саморекурсивної функції для виклику хвоста все, що вам потрібно, - це GOTO в рамках одного методу . Таким чином, це обмеження не виключає ТСО саморекурсивних методів.
Алекс Д
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.