Чому .NET / C # не оптимізується для рекурсії хвостових викликів?


111

Я знайшов це питання про те, які мови оптимізують хвостову рекурсію. Чому C # не оптимізує рекурсію хвоста, коли це можливо?

Для конкретного випадку, чому цей метод не оптимізований у цикл ( Visual Studio 2008 32-розрядний, якщо це має значення) ?:

private static void Foo(int i)
{
    if (i == 1000000)
        return;

    if (i % 100 == 0)
        Console.WriteLine(i);

    Foo(i+1);
}

Сьогодні я читав книгу "Структури даних", яка роздвоює рекурсивну функцію на дві частини preemptive(наприклад, факторний алгоритм) та Non-preemptive(наприклад, функція ackermann). Автор наводив лише два приклади, які я згадував, не даючи належних міркувань цього роздвоєння. Чи подібна ця біфуркація як хвостова та нехвістова рекурсивні функції?
RBT

5
Корисна розмова про це Джон Скейт та Скотт Хензельман 2016 року youtu.be/H2KkiRbDZyc?t=3302
Даніель Б

@RBT: Я думаю, що це інакше. Це стосується кількості рекурсивних дзвінків. Хвостові дзвінки стосуються дзвінків, які відображаються в положенні хвоста, тобто останнє, що робить функція, вона повертає результат безпосередньо від виклику.
JD

Відповіді:


84

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

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

Сам CLR підтримує оптимізацію хвостових викликів, але компілятор, орієнтований на мову, повинен знати, як генерувати відповідний опкод, і JIT повинен бути готовим його поважати. F # ssc генерує відповідні опкоди (хоча для простої рекурсії він може просто перетворити всю річ у whileцикл безпосередньо). C # csc не робить.

Дивіться цю публікацію в блозі для отримання детальної інформації (цілком можливо, тепер застаріла, враховуючи останні зміни JIT). Зауважте, що зміни CLR для 4.0 x86, x64 та ia64 будуть дотримуватися цього .


2
Дивіться також цей пост: social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/… де я виявляю, що хвіст повільніше, ніж звичайний дзвінок. Eep!
плінтус

77

Це подання зворотного зв’язку Microsoft Connect має відповісти на ваше запитання. Він містить офіційну відповідь від Microsoft, тому рекомендую йти далі.

Дякую за пропозицію. Ми розглянули можливість випускати інструкції щодо виклику хвостів у ряді моментів у розробці компілятора C #. Однак є деякі тонкі проблеми, які підштовхнули нас до уникнення цього: 1) Насправді нетривіальні накладні витрати на використання інструкції .tail в CLR (це не просто інструкція зі стрибків, оскільки кінцеві виклики хвоста в кінцевому підсумку стають у багатьох менш суворих середовищах, таких як функціональні середовища виконання мови, де виклики хвостів значно оптимізовані). 2) Є кілька реальних методів C #, де було б законно випускати хвостові виклики (інші мови заохочують схеми кодування, які мають більше хвостових рекурсій, і багато хто, що значною мірою покладається на оптимізацію хвостових викликів, насправді роблять глобальне переписування (наприклад, перетворення «Продовження проходження»), щоб збільшити кількість хвостової рекурсії). 3) Частково через 2) випадки, коли методи C # укладають переповнення через глибоку рекурсію, яка мала бути успішною, досить рідкісні.

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

До речі, як це було зазначено, варто відзначити , що хвостова рекурсія буде оптимізована на x64.


3
Ви могли б знайти це корисно теж: weblogs.asp.net/podwysocki/archive/2008/07/07 / ...
нолдорін

Немає проблем, радий, що ти вважаєш це корисним.
Нолдорін

17
Дякую, що цитували його, бо зараз це 404!
Роман Старков

3
Зараз посилання виправлено.
luksan

15

C # не оптимізується для рекурсії із зворотним викликом, тому що саме це F #!

Дещо глибше про умови, які заважають компілятору C # здійснювати оптимізацію хвостових викликів, див. Цю статтю: Умови JIT CLR хвостового виклику .

Сумісність між C # і F #

C # і F # взаємодіють дуже добре, а оскільки .NET Common Language Runtime (CLR) розроблений з урахуванням такої сумісності, кожна мова розроблена з оптимізаціями, які є специфічними для її наміру та цілей. Для прикладу, який показує, як легко зателефонувати F # коду з C # коду, див. Виклик F # коду з C # коду ; для прикладу виклику функцій C # з коду F # див. Виклик функцій C # з F # .

Про сумісність делегата див. У цій статті: Делегувати сумісність між F #, C # та Visual Basic .

Теоретичні та практичні відмінності між C # і F #

Ось стаття, яка висвітлює деякі відмінності та пояснює дизайнерські відмінності рекурсії виклику хвостами між C # та F #: Створення коду виклику Tail-Call у C # та F # .

Ось стаття з деякими прикладами в C #, F # і C ++ \ CLI: Пригоди в хвості Рекурсія в C #, F # і C ++ \ CLI

Основна теоретична відмінність полягає в тому, що C # розроблений з петлями, тоді як F # розроблений за принципами обчислення Лямбди. Про дуже гарну книгу про принципи обчислення Лямбди дивіться у цій безкоштовній книзі: Структура та інтерпретація комп’ютерних програм Абельсона, Суссмана та Суссмана .

Про дуже хорошу вступну статтю про хвостові дзвінки у F # див. Цю статтю: Детальний вступ до хвостових дзвінків у F # . Нарешті, ось стаття, яка висвітлює різницю між не хвостовою рекурсією та рекурсією виклику хвоста (у F #): Хвост-рекурсія проти нехвістової рекурсії у F різкій .


8

Нещодавно мені сказали, що компілятор C # на 64 біт оптимізує хвостову рекурсію.

C # також це реалізує. Причина, чому вона не завжди застосовується, полягає в тому, що правила, застосовувані для застосування хвостової рекурсії, дуже суворі.


8
X64 джиттера робить це, але C # компілятор
Марк Sowul

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

3
Щойно для уточнення цих двох коментарів, C # ніколи не висилає CIL 'хвостовий' код, і я вважаю, що це все-таки вірно в 2017 році. Однак для всіх мов цей опкод завжди доцільний лише в тому сенсі, що відповідні тремтіння (x86, x64 ) мовчки проігнорує його, якщо різні умови не будуть дотримані (ну ніякої помилки, крім можливого переповнення стека ). Це пояснює, чому ви змушені дотримуватися "хвіст" з "ret" - це для цього випадку. Тим часом, тремтячі також можуть застосовувати оптимізацію, коли в CIL немає префікса "хвоста", знову ж таки, як вважають за потрібне, і незалежно від мови .NET
Гленн Слейден

3

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


Батути інвазивні (вони є глобальною зміною конвенції про виклик), ~ 10 разів повільніше, ніж правильне усунення хвостових викликів, і вони затьмарюють усю інформацію про стеки стека, що ускладнює налагодження та код профілю
JD

1

Як було сказано в інших відповідях, CLR підтримує оптимізацію хвостових викликів, і, схоже, історично було вдосконалено. Але підтримка його в C # має відкрите Proposalпитання у сховищі git для проектування мови програмування C # Підтримка хвостової рекурсії # 2544 .

Тут ви можете знайти деякі корисні деталі та інформацію. Наприклад, згадується @jaykrell

Дозвольте дати те, що я знаю.

Іноді хвостовий виклик - це безпрограшна робота. Це може зберегти процесор. jmp дешевше, ніж call / ret Це може зберегти стек. Якщо торкнутися менше стека, це покращить місцевість.

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

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

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

Також дозвольте мені зазначити (як додаткову інформацію). Коли ми створюємо компільовану лямбда, використовуючи класи виразів у System.Linq.Expressionsпросторі імен, є аргумент під назвою "tailCall", що, як пояснено у коментарі, це

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

Мене ще не пробували, і я не впевнений, як це може допомогти, пов'язане з вашим запитанням, але, ймовірно, хтось може спробувати це і може бути корисним у деяких сценаріях:


var myFuncExpression = System.Linq.Expressions.Expression.Lambda<Func<  >>(body:  , tailCall: true, parameters:  );

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