Por que o .NET / C # não otimiza a recursão da chamada final?

111

Eu encontrei esta pergunta sobre quais linguagens otimizam a recursão da cauda. Por que o C # não otimiza a recursão da cauda, ​​sempre que possível?

Para um caso concreto, por que este método não é otimizado em um loop ( Visual Studio 2008 de 32 bits, se for o caso) ?:

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

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

    Foo(i+1);
}
ripper234
fonte
Eu estava lendo um livro sobre Estruturas de Dados hoje que bifurca a função recursiva em duas, a saber preemptive(por exemplo, algoritmo fatorial) e Non-preemptive(por exemplo, função de ackermann). O autor deu apenas dois exemplos que mencionei, sem dar um raciocínio adequado por trás dessa bifurcação. Essa bifurcação é igual às funções recursivas caudas e não caudais?
RBT
5
Conversa útil sobre isso por Jon skeet e Scott Hanselman em 2016 youtu.be/H2KkiRbDZyc?t=3302
Daniel B
@RBT: Acho que é diferente. Refere-se ao número de chamadas recursivas. Chamadas finais são sobre chamadas que aparecem na posição final, ou seja, a última coisa que uma função faz é retornar o resultado do receptor diretamente.
JD

Respostas:

84

A compilação JIT é um ato de equilíbrio complicado entre não gastar muito tempo fazendo a fase de compilação (assim, desacelerando consideravelmente os aplicativos de curta duração) e não fazer análise suficiente para manter o aplicativo competitivo no longo prazo com uma compilação antecipada padrão .

Curiosamente, as etapas de compilação do NGen não têm como objetivo ser mais agressivas em suas otimizações. Suspeito que isso seja porque eles simplesmente não querem ter bugs em que o comportamento dependa de se o JIT ou NGen foi o responsável pelo código de máquina.

O próprio CLR oferece suporte à otimização de chamada final, mas o compilador específico da linguagem deve saber como gerar o opcode relevante e o JIT deve estar disposto a respeitá-lo. O fsc do F # irá gerar os opcodes relevantes (embora para uma recursão simples ele possa apenas converter a coisa inteira em um whileloop diretamente). O csc do C # não.

Veja esta postagem do blog para alguns detalhes (possivelmente agora desatualizado devido às mudanças recentes no JIT). Observe que as alterações do CLR para 4.0 x86, x64 e ia64 irão respeitá-lo .

ShuggyCoUk
fonte
2
Consulte também esta postagem: social.msdn.microsoft.com/Forums/en-US/netfxtoolsdev/thread/… em que descobri que a cauda é mais lenta do que uma chamada normal. Eep!
plinto de
77

Este envio de feedback do Microsoft Connect deve responder à sua pergunta. Ele contém uma resposta oficial da Microsoft, então eu recomendo ir por ela.

Obrigado pela sugestão. Consideramos a emissão de instruções de chamada final em vários pontos no desenvolvimento do compilador C #. No entanto, existem alguns problemas sutis que nos levaram a evitar isso até agora: 1) Na verdade, há um custo indireto não trivial para usar a instrução .tail no CLR (não é apenas uma instrução de salto, já que as chamadas finais acabam se tornando em muitos ambientes menos restritos, como ambientes de tempo de execução de linguagem funcional, onde as chamadas finais são altamente otimizadas). 2) Existem poucos métodos reais de C # onde seria legal emitir chamadas finais (outras linguagens encorajam padrões de codificação que têm mais recursão final, e muitos que dependem fortemente da otimização da chamada final, na verdade, fazem reescrita global (como transformações de passagem de continuação) para aumentar a quantidade de recursão final). 3) Em parte devido a 2), os casos em que os métodos C # estouram em pilha devido à recursão profunda que deveriam ter ocorrido são bastante raros.

Dito isso, continuamos a olhar para isso e podemos, em uma versão futura do compilador, encontrar alguns padrões nos quais faz sentido emitir instruções .tail.

A propósito, como foi apontado, é importante notar que a recursão da cauda é otimizada em x64.

Noldorin
fonte
3
Você também pode achar isso útil: weblogs.asp.net/podwysocki/archive/2008/07/07/…
Noldorin
Sem problemas, fico feliz que você ache útil.
Noldorin
17
Obrigado por citá-lo, porque agora é um 404!
Roman Starkov
3
O link agora está corrigido.
luksan
15

C # não otimiza para recursão de chamada final porque é para isso que o F # serve!

Para obter mais detalhes sobre as condições que impedem o compilador C # de realizar otimizações de chamada final, consulte este artigo: Condições de chamada final JIT CLR .

Interoperabilidade entre C # e F #

C # e F # interoperam muito bem e, como o .NET Common Language Runtime (CLR) foi projetado com essa interoperabilidade em mente, cada linguagem é projetada com otimizações que são específicas para sua finalidade e objetivos. Para obter um exemplo que mostra como é fácil chamar o código F # a partir do código C #, consulte Chamando o código F # a partir do código C # ; para obter um exemplo de chamada de funções C # a partir do código F #, consulte Chamando funções C # a partir de F # .

Para delegar interoperabilidade, consulte este artigo: Delegar interoperabilidade entre F #, C # e Visual Basic .

Diferenças teóricas e práticas entre C # e F #

Aqui está um artigo que cobre algumas das diferenças e explica as diferenças de design da recursão de chamada final entre C # e F #: Gerando Opcode de chamada final em C # e F # .

Aqui está um artigo com alguns exemplos em C #, F # e C ++ \ CLI: Aventuras na recursão de cauda em C #, F # e C ++ \ CLI

A principal diferença teórica é que o C # é projetado com loops, enquanto o F # é projetado com base nos princípios do cálculo Lambda. Para obter um livro muito bom sobre os princípios do cálculo Lambda, consulte este livro gratuito: Structure and Interpretation of Computer Programs, de Abelson, Sussman e Sussman .

Para obter um artigo introdutório muito bom sobre chamadas finais em F #, consulte este artigo: Introdução detalhada às chamadas finais em F # . Finalmente, aqui está um artigo que cobre a diferença entre recursão não cauda e recursão de chamada de cauda (em F #): Recursão de cauda vs. recursão não cauda em F sustenido .

devinbost
fonte
8

Disseram-me recentemente que o compilador C # para 64 bits otimiza a recursão final.

C # também implementa isso. O motivo pelo qual nem sempre é aplicado é que as regras usadas para aplicar a recursão de cauda são muito rígidas.

Alexandre Brisebois
fonte
8
O jitter x64 faz isso, mas o compilador C # não
Mark Sowul
Obrigado pela informação. Este é o branco diferente do que eu pensava anteriormente.
Alexandre Brisebois
3
Apenas para esclarecer esses dois comentários, C # nunca emite o opcode CIL 'final', e eu acredito que isso ainda seja verdade em 2017. No entanto, para todas as linguagens, esse opcode é sempre consultivo apenas no sentido de que os respectivos jitters (x86, x64 ) irá ignorá-lo silenciosamente se várias condições não forem atendidas (bem, nenhum erro, exceto possível estouro de pilha ). Isso explica porque você é forçado a seguir a 'cauda' com 'ret' - é para este caso. Enquanto isso, os jitters também são livres para aplicar a otimização quando não há prefixo 'final' no CIL, novamente conforme considerado apropriado, e independentemente da linguagem .NET.
Glenn Slayden
3

Você pode usar a técnica do trampolim para funções recursivas de cauda em C # (ou Java). No entanto, a melhor solução (se você se preocupa apenas com a utilização da pilha) é usar este pequeno método auxiliar para agrupar partes da mesma função recursiva e torná-la iterativa enquanto mantém a função legível.

naiem
fonte
Os trampolins são invasivos (são uma mudança global na convenção de chamada), cerca de 10 vezes mais lentos do que a eliminação de chamadas finais adequadas e ofuscam todas as informações de rastreamento de pilha, tornando muito mais difícil depurar e criar perfis
JD
1

Como outras respostas mencionadas, CLR oferece suporte à otimização de chamada final e parece que historicamente sofreu melhorias progressivas. Mas suportá-lo em C # tem um Proposalproblema aberto no repositório git para o design da linguagem de programação C # Support tail recursion # 2544 .

Você pode encontrar alguns detalhes e informações úteis lá. Por exemplo @jaykrell mencionado

Deixe-me dar o que eu sei.

Às vezes, o tailcall é um ganho de desempenho. Isso pode economizar CPU. jmp é mais barato do que call / ret Pode economizar pilha. Tocar em menos pilha melhora a localização.

Às vezes, o tailcall é uma perda de desempenho, uma vitória de pilha. O CLR tem um mecanismo complexo no qual passa mais parâmetros ao receptor do que aquele que recebeu. Quero dizer especificamente mais espaço de pilha para parâmetros. Isso é lento. Mas conserva pilha. Ele só fará isso com a cauda. prefixo.

Se os parâmetros do chamador forem maiores que os parâmetros do chamador, geralmente é uma transformação ganha-ganha muito fácil. Pode haver fatores como a alteração da posição do parâmetro de gerenciado para inteiro / flutuante e gerar StackMaps precisos e outros.

Agora, há outro ângulo, aquele dos algoritmos que exigem a eliminação do tailcall, para fins de serem capazes de processar dados arbitrariamente grandes com pilha fixa / pequena. Não se trata de desempenho, mas sim de capacidade de execução.

Também deixe-me mencionar (como informação extra), quando estamos gerando um lambda compilado usando classes de expressão no System.Linq.Expressionsnamespace, há um argumento chamado 'tailCall' que, conforme explicado em seu comentário, é

Um booleano que indica se a otimização da chamada final será aplicada ao compilar a expressão criada.

Ainda não experimentei e não tenho certeza de como pode ajudar em relação à sua dúvida, mas provavelmente alguém pode tentar e pode ser útil em alguns cenários:


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

var myFunc =  myFuncExpression.Compile();
estilo de vida
fonte