Сгенерировать код вызова хвостового вызова

Из любопытства я пытался создать код операции хвоста с помощью С#. Fibinacci прост, поэтому мой пример С# выглядит так:

    private static void Main(string[] args)
    {
        Console.WriteLine(Fib(int.MaxValue, 0));
    }

    public static int Fib(int i, int acc)
    {
        if (i == 0)
        {
            return acc;
        }

        return Fib(i - 1, acc + i);
    }

Если я создам его в выпуске и запускаю его без отладки, я не получаю переполнение стека. Отладка или запуск без оптимизаций, и я получаю переполнение стека, подразумевая, что хвостовой вызов работает, когда в релизе с оптимизацией (что и ожидалось).

MSIL для этого выглядит так:

.method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x205e
    // Code Size 17 (0x11)
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: brtrue.s L_0005
    L_0003: ldarg.1 
    L_0004: ret 
    L_0005: ldarg.0 
    L_0006: ldc.i4.1 
    L_0007: sub 
    L_0008: ldarg.1 
    L_0009: ldarg.0 
    L_000a: add 
    L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32)
    L_0010: ret 
}

Я бы ожидал увидеть код операции хвоста, на msdn, но он не существует. Это заставило меня задаться вопросом, отвечает ли JIT-компилятор за его размещение? Я попытался выполнить сборку (используя ngen install <exe>, перейдя к списку сборок Windows, чтобы получить его) и загрузите его в ILSpy, но он выглядит так же:

.method public hidebysig static int32 Fib(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x3bfe
    // Code Size 17 (0x11)
    .maxstack 8
    L_0000: ldarg.0 
    L_0001: brtrue.s L_0005
    L_0003: ldarg.1 
    L_0004: ret 
    L_0005: ldarg.0 
    L_0006: ldc.i4.1 
    L_0007: sub 
    L_0008: ldarg.1 
    L_0009: ldarg.0 
    L_000a: add 
    L_000b: call int32 [ConsoleApplication2]ConsoleApplication2.Program::Fib(int32,int32)
    L_0010: ret 
}

Я все еще не вижу этого.

Я знаю, что F # отлично справляется с хвостом, поэтому я хотел сравнить то, что сделал F # с тем, что сделал С#. Мой пример F # выглядит следующим образом:

let rec fibb i acc =  
    if i = 0 then
        acc
    else 
        fibb (i-1) (acc + i)


Console.WriteLine (fibb 3 0)

И сгенерированный IL для метода fib выглядит следующим образом:

.method public static int32 fibb(int32 i, int32 acc) cil managed
{
    // Method Start RVA 0x2068
    // Code Size 18 (0x12)
    .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationArgumentCountsAttribute::.ctor(int32[]) = { int32[](Mono.Cecil.CustomAttributeArgument[]) }
    .maxstack 5
    L_0000: nop 
    L_0001: ldarg.0 
    L_0002: brtrue.s L_0006
    L_0004: ldarg.1 
    L_0005: ret 
    L_0006: ldarg.0 
    L_0007: ldc.i4.1 
    L_0008: sub 
    L_0009: ldarg.1 
    L_000a: ldarg.0 
    L_000b: add 
    L_000c: starg.s acc
    L_000e: starg.s i
    L_0010: br.s L_0000
}

Что, согласно ILSpy, эквивалентно этому:

[Microsoft.FSharp.Core.CompilationArgumentCounts(Mono.Cecil.CustomAttributeArgument[])]
public static int32 fibb(int32 i, int32 acc)
{
    label1:
    if !(((i != 0))) 
    {
        return acc;
    }
    (i - 1);
    i = acc = (acc + i);;
    goto label1;
}

Итак, сгенерированный хвостовой вызов F # с использованием операторов goto? Это не то, чего я ожидал.

Я не пытаюсь полагаться на хвостовой вызов в любом месте, но мне просто интересно, где именно этот код операции установлен? Как С# делает это?

Ответы

Ответ 1

Компилятор С# не дает вам никаких гарантий относительно оптимизации хвостовых вызовов, потому что программы на С# обычно используют циклы, и поэтому они не полагаются на оптимизацию хвостового вызова. Таким образом, в С# это просто оптимизация JIT, которая может или не может произойти (и вы не можете полагаться на нее).

Компилятор F # предназначен для обработки функционального кода, который использует рекурсию, и поэтому дает вам определенные гарантии в отношении хвостовых вызовов. Это делается двумя способами:

  • если вы пишете рекурсивную функцию, которая вызывает себя (например, ваш fib), компилятор превращает ее в функцию, которая использует цикл в теле (это простая оптимизация, а полученный код выполняется быстрее, чем использование хвост-вызов)

  • если вы используете рекурсивный вызов в более сложной позиции (при использовании стиля продолжения передачи, где функция передается в качестве аргумента), тогда компилятор генерирует команду хвостового вызова, которая сообщает JIT, что она должна использовать хвост.

В качестве примера второго случая скомпилируйте следующую простую функцию F # (F # не делает этого в режиме Debug для упрощения отладки, поэтому вам может понадобиться режим Release или добавить --tailcalls+):

let foo a cont = cont (a + 1)

Функция просто вызывает функцию cont с первым аргументом, увеличенным на единицу. В продолжении стиля прохода у вас длинная последовательность таких вызовов, поэтому оптимизация имеет решающее значение (вы просто не можете использовать этот стиль без обработки хвостовых вызовов). Генерирует код IL выглядит следующим образом:

IL_0000: ldarg.1
IL_0001: ldarg.0
IL_0002: ldc.i4.1
IL_0003: add
IL_0004: tail.                          // Here is the 'tail' opcode!
IL_0006: callvirt instance !1 
  class [FSharp.Core] Microsoft.FSharp.Core.FSharpFunc`2<int32, !!a>::Invoke(!0)
IL_000b: ret

Ответ 2

Ситуация с оптимизацией хвостового вызова в .Net довольно сложна. Насколько я знаю, это так:

  • Компилятор С# никогда не будет генерировать код операции tail., и он также никогда не сделает оптимизацию вызова хвоста сам по себе.
  • Компилятор F # иногда испускает код операции tail., а иногда и оптимизацию хвостового вызова сам по себе, испуская ИЛ, что не рекурсивно.
  • CLR будет оценивать код операции tail., если он присутствует, и 64-разрядная среда CLR иногда будет выполнять оптимизацию хвостового вызова даже тогда, когда код операции отсутствует.

Итак, в вашем случае вы не видели код операции tail. в IL, сгенерированный компилятором С#, потому что он этого не делает. Но метод был оптимизирован с помощью "хвоста", потому что CLR иногда делает это даже без кода операции.

И в случае F # вы заметили, что компилятор f # выполнил оптимизацию самостоятельно.

Ответ 3

Как и все оптимизации, выполняемые в .NET(языки Roslyn), оптимизация хвостовых вызовов - это работа, выполняемая дрожанием, а не компилятором. Философия заключается в том, что выполнение задания по джиттеру полезно, поскольку любой язык будет им полезен, и обычно сложная работа по написанию и отладке оптимизатора кода должна выполняться только один раз для каждой архитектуры.

Вы должны посмотреть на сгенерированный машинный код, чтобы увидеть, как это делается, Debug + Windows + Disassembly. С дополнительным требованием, которое вы делаете, просмотрев код сборки Release, который сгенерирован с помощью опций Tools + Options, Debugging, General, Suppress JIT, отключен.

Код x64 выглядит так:

        public static int Fib(int i, int acc) {
            if (i == 0) {
00000000  test        ecx,ecx 
00000002  jne         0000000000000008 
                return acc;
00000004  mov         eax,edx 
00000006  jmp         0000000000000011 
            }

            return Fib(i - 1, acc + i);
00000008  lea         eax,[rcx-1] 
0000000b  add         edx,ecx 
0000000d  mov         ecx,eax 
0000000f  jmp         0000000000000000              // <== here!!!
00000011  rep ret  

Обратите внимание на отмеченную инструкцию, вместо вызова. Оптимизация хвостового звонка на работе. Причуда в .NET заключается в том, что 32-разрядный джиттер x86 не выполняет эту оптимизацию. Просто занятый предмет, с которым они, вероятно, никогда не обойдутся. Который требовал от авторов компилятора F # не игнорировать проблему и испускать Opcodes.Tailcall. Вы найдете другие оптимизации, выполненные джиттером, описанные в этом ответе.