Quando os custos das chamadas de função ainda são importantes nos compiladores modernos?

95

Eu sou uma pessoa religiosa e faço esforços para não cometer pecados. É por isso que costumo escrever funções pequenas ( menores que isso , para reformular Robert C. Martin) para cumprir os vários mandamentos ordenados pela Bíblia do Código Limpo . Mas enquanto checava algumas coisas, cheguei neste post , abaixo do qual li este comentário:

Lembre-se de que o custo de uma chamada de método pode ser significativo, dependendo do idioma. Quase sempre há uma troca entre escrever código legível e escrever código de desempenho.

Em que condições essa declaração citada ainda é válida hoje em dia, dada a rica indústria de compiladores modernos com bom desempenho?

Essa é a minha única pergunta. E não se trata de escrever funções longas ou pequenas. Apenas enfatizo que seu feedback pode ou não contribuir para alterar minha atitude e me deixa incapaz de resistir à tentação dos blasfemadores .

Billal Begueradj
fonte
11
Escreva código legível e de manutenção. Só quando você enfrenta um problema com estouro de pilha pode repensar a sua spproach
Fabio
33
Uma resposta geral aqui é impossível. Existem muitos compiladores diferentes, implementando muitas especificações de idioma diferentes. E existem linguagens compiladas pelo JIT, linguagens interpretadas dinamicamente e assim por diante. Basta dizer que, se você estiver compilando código C ou C ++ nativo com um compilador moderno, não precisará se preocupar com os custos de uma chamada de função. O otimizador irá incorporá-las sempre que apropriado. Como um entusiasta da micro-otimização, raramente vejo os compiladores tomando decisões inline que eu ou meus benchmarks não concordam.
Cody cinzento
6
Falando por experiência pessoal, escrevo código em uma linguagem proprietária bastante moderna em termos de capacidade, mas as chamadas de funções são ridiculamente caras, a ponto de mesmo os loops típicos terem que ser otimizados para velocidade: em for(Integer index = 0, size = someList.size(); index < size; index++)vez de simplesmente for(Integer index = 0; index < someList.size(); index++). Só porque o seu compilador foi criado nos últimos anos não significa necessariamente que você pode renunciar à criação de perfil.
phyrfox
5
@phyrfox que apenas faz sentido, obtendo o valor de someList.size () fora do loop, em vez de chamá-lo todas as vezes através do loop. Isso é especialmente verdadeiro se houver alguma chance de um problema de sincronização em que leitores e gravadores possam tentar entrar em conflito durante a iteração. Nesse caso, você também desejaria proteger a lista contra quaisquer alterações durante a iteração.
Craig
8
Cuidado para não levar muito as funções pequenas, ele pode ofuscar o código com a mesma eficiência que uma mega-função monolítica. Se você não acredita em mim, confira alguns dos vencedores do ioccc.org : alguns codificam tudo em um único main(), outros dividem tudo em 50 pequenas funções e todos são totalmente ilegíveis. O truque é, como sempre, encontrar um bom equilíbrio .
C11

Respostas:

148

Depende do seu domínio.

Se você estiver escrevendo um código para um microcontrolador de baixa potência, o custo da chamada do método pode ser significativo. Mas se você estiver criando um site ou aplicativo normal, o custo da chamada de método será insignificante em comparação com o restante do código. Nesse caso, sempre vale mais a pena focar nos algoritmos e estruturas de dados corretos, em vez de micro-otimizações, como chamadas de método.

E também há a questão do compilador incluir os métodos para você. A maioria dos compiladores é inteligente o suficiente para incorporar funções onde for possível.

E por último, há uma regra de ouro do desempenho: SEMPRE PERFIL PRIMEIRO. Não escreva código "otimizado" com base em suposições. Se você não estiver em uso, escreva os dois casos e veja qual é o melhor.

Eufórico
fonte
13
E, por exemplo, o compilador HotSpot performes Inlining especulativa , que é, de alguma inlining sentido, mesmo quando é não possível.
Jörg W Mittag
49
Na verdade, em uma aplicação web, a todo o código é provavelmente insignificante em relação ao acesso DB e o tráfego de rede ...
AnoE
72
Na verdade, eu gosto de energia embarcada e ultra baixa com um compilador muito antigo que mal sabe o que significa otimização e, acredite em mim, mesmo que a função seja importante, nunca é o primeiro lugar para procurar otimização. Mesmo neste domínio de nicho, a qualidade do código vem em primeiro lugar neste caso.
Tim
2
@Mehrdad Mesmo neste caso, eu ficaria surpreso se não houvesse algo mais relevante para otimizar no código. Ao traçar o perfil do código, vejo as coisas muito mais pesadas do que as funções chamam, e é aí que é relevante procurar otimização. Alguns desenvolvedores ficam loucos por um ou dois LOC não otimizados, mas quando você analisa o SW, percebe que o design é mais importante do que isso, pelo menos na maior parte do código. Quando você encontra o gargalo, pode tentar otimizá-lo, e isso terá muito mais impacto do que a otimização arbitrária de baixo nível, como escrever grandes funções para evitar a sobrecarga das chamadas.
Tim
8
Boa resposta! Seu último ponto deve ser o primeiro: sempre perfil antes de decidir onde otimizar .
CJ Dennis
56

A sobrecarga da chamada de função depende inteiramente do idioma e em que nível você está otimizando.

Em um nível ultra baixo, as chamadas de função e ainda mais as chamadas de método virtual podem ser caras se levarem a erros de previsão de ramificação ou falhas no cache da CPU. Se você escreveu o assembler , também saberá que precisa de algumas instruções extras para salvar e restaurar registros em torno de uma chamada. Não é verdade que um compilador "suficientemente inteligente" seria capaz de incorporar as funções corretas para evitar essa sobrecarga, porque os compiladores são limitados pela semântica da linguagem (especialmente em torno de recursos como envio de método de interface ou bibliotecas carregadas dinamicamente).

Em um nível alto, linguagens como Perl, Python, Ruby fazem muita contabilidade por chamada de função, tornando-as comparativamente caras. Isso é agravado pela metaprogramação. Uma vez eu acelerei um software Python 3x apenas ao elevar as chamadas de função de um loop muito quente. No código crítico de desempenho, as funções auxiliares embutidas podem ter um efeito perceptível.

Mas a grande maioria dos softwares não é tão crítica em termos de desempenho que você seria capaz de perceber as despesas gerais das chamadas de função. De qualquer forma, escrever um código simples e limpo compensa:

  • Se o seu código não é crítico para o desempenho, isso facilita a manutenção. Mesmo em softwares críticos para o desempenho, a maioria do código não será um "ponto de acesso".

  • Se o seu código é crítico para o desempenho, o código simples facilita a compreensão do código e identifica oportunidades de otimização. As maiores vitórias geralmente não vêm de micro-otimizações, como funções embutidas, mas de melhorias algorítmicas. Ou formulado de maneira diferente: não faça a mesma coisa mais rápido. Encontre uma maneira de fazer menos.

Observe que "código simples" não significa "fatorado em mil pequenas funções". Toda função também introduz um pouco de sobrecarga cognitiva - é mais difícil argumentar sobre um código mais abstrato. Em algum momento, essas pequenas funções podem fazer tão pouco que não usá-las simplificaria seu código.

amon
fonte
16
Um DBA realmente inteligente me disse uma vez "Normalize até doer, depois desnormalize até que não doa". Parece-me que poderia ser reformulado para "Extrair métodos até doer, e alinhar até que não doa".
precisa saber é o seguinte
1
Além da sobrecarga cognitiva, há uma sobrecarga simbólica nas informações do depurador, e geralmente a sobrecarga nos binários finais é inevitável.
21817 Frank Hileman
Em relação aos compiladores inteligentes - eles PODEM fazer isso, nem sempre. Por exemplo, a jvm pode alinhar coisas com base no perfil de tempo de execução com interceptação muito barata / gratuita para caminho incomum ou função polimórfica embutida para a qual existe apenas uma implementação de determinado método / interface e, em seguida, desoptimizar a chamada para polimórfica corretamente quando uma nova subclasse é carregada dinamicamente tempo de execução. Mas sim, existem muitos idiomas em que essas coisas não são possíveis e muitos casos, mesmo na jvm, quando não é rentável ou possível em geral.
Artur Biesiadowski
19

Quase todos os ditados sobre o ajuste do código para desempenho são casos especiais da lei de Amdahl . A declaração curta e bem-humorada da lei de Amdahl é

Se uma parte do seu programa ocupa 5% do tempo de execução, e você a otimiza para que agora consuma zero por cento do tempo de execução, o programa como um todo será apenas 5% mais rápido.

(Otimizar tudo para zero por cento do tempo de execução é totalmente possível: quando você se senta para otimizar um programa grande e complicado, é provável que descubra que ele está gastando pelo menos parte do tempo de execução em coisas que não precisa fazer nada .)

É por isso que as pessoas normalmente dizem que não se preocupam com os custos das chamadas de funções: não importa o quanto sejam caras, normalmente o programa como um todo gasta apenas uma pequena fração de seu tempo de execução em sobrecarga de chamadas, portanto, acelerá-los não ajuda muito .

Mas, se houver um truque que você possa executar que torne todas as chamadas de função mais rápidas, esse truque provavelmente valerá a pena. Os desenvolvedores de compiladores gastam muito tempo otimizando a função "prólogos" e "epílogos", porque isso beneficia todos os programas compilados com esse compilador, mesmo que seja apenas um pouquinho para cada um.

E, se você tiver motivos para acreditar que um programa está gastando muito tempo de execução apenas fazendo chamadas de função, comece a pensar se algumas dessas chamadas de função são desnecessárias. Aqui estão algumas regras práticas para saber quando você deve fazer isso:

  • Se o tempo de execução por invocação de uma função for menor que um milissegundo, mas essa função for chamada centenas de milhares de vezes, provavelmente deverá ser incorporada.

  • Se um perfil do programa mostra milhares de funções e nenhuma delas ocupa mais de 0,1% ou mais do tempo de execução, a sobrecarga de chamada de função provavelmente é significativa em termos agregados.

  • Se você tiver " código de lasanha " , no qual existem muitas camadas de abstração que dificilmente funcionam além do envio para a próxima camada, e todas essas camadas são implementadas com chamadas de método virtual, há uma boa chance de a CPU estar desperdiçando um muito tempo em barracas de tubulação de ramificação indireta. Infelizmente, a única cura para isso é livrar-se de algumas camadas, o que geralmente é muito difícil.

zwol
fonte
7
Apenas tome cuidado com coisas caras feitas profundamente em loops aninhados. Otimizei uma função e obtive um código que roda 10 vezes mais rápido. Isso foi depois que o criador de perfis apontou o culpado. (Foi chamado repetidamente, em ciclos de O (n ^ 3) para um pequeno n O (n ^ 6).)
Loren Pechtel
"Infelizmente, a única cura para isso é livrar-se de algumas camadas, o que geralmente é muito difícil". - isso depende muito do seu compilador de idiomas e / ou tecnologia de máquina virtual. Se você pode modificar o código para facilitar a integração do compilador (por exemplo, usando finalclasses e métodos onde aplicável em Java, ou não virtualmétodos em C # ou C ++), o indireto pode ser eliminado pelo compilador / tempo de execução e você ' Veremos um ganho sem reestruturação maciça. Como aponta @JorgWMittag acima, a JVM pode até mesmo embutido nos casos em que não é provável que a otimização é ...
Jules
... válido, então pode ser que ele esteja fazendo isso no seu código, apesar das camadas de qualquer maneira.
Jules
@Jules Embora seja verdade que os compiladores JIT podem executar otimização especulativa, isso não significa que essas otimizações sejam aplicadas uniformemente. Especificamente em relação ao Java, minha experiência é que a cultura do desenvolvedor favorece camadas empilhadas em cima de camadas, resultando em pilhas de chamadas extremamente profundas. Curiosamente, isso contribui para a sensação lenta e inchada de muitos aplicativos Java. Essa arquitetura altamente em camadas funciona no tempo de execução do JIT, independentemente de as camadas serem tecnicamente inlináveis. O JIT não é uma bala mágica que pode corrigir automaticamente problemas estruturais.
amon
@ amon Minha experiência com "código de lasanha" vem de aplicativos C ++ muito grandes, com muito código datado dos anos 90, quando hierarquias de objetos profundamente aninhadas e COM eram a moda. Os compiladores C ++ envidam esforços bastante heróicos para eliminar as penalidades de abstração em programas como esse, e ainda assim você pode vê-los gastando uma fração significativa do tempo de execução do relógio de parede em paradas de pipeline de ramificação indireta (e outra parte significativa de erros de cache I) .
Zwol 17/09
17

Vou desafiar esta citação:

Quase sempre há uma troca entre escrever código legível e escrever código de desempenho.

Esta é uma afirmação realmente enganosa e uma atitude potencialmente perigosa. Existem alguns casos específicos em que você precisa fazer uma troca, mas, em geral, os dois fatores são independentes.

Um exemplo de uma troca necessária é quando você tem um algoritmo simples versus um mais complexo, mas com mais desempenho. Uma implementação de hashtable é claramente mais complexa do que uma implementação de lista vinculada, mas a pesquisa será mais lenta, portanto, você pode precisar trocar a simplicidade (que é um fator de legibilidade) para o desempenho.

Com relação à sobrecarga de chamada de função, transformar um algoritmo recursivo em uma iterativa pode ter um benefício significativo, dependendo do algoritmo e do idioma. Mas esse é novamente um cenário muito específico e, em geral, a sobrecarga das chamadas de função será desprezível ou otimizada.

(Algumas linguagens dinâmicas como o Python têm uma sobrecarga significativa de chamada de método. Mas se o desempenho se tornar um problema, você provavelmente não deveria estar usando o Python em primeiro lugar.)

A maioria dos princípios para código legível - formatação consistente, nomes de identificadores significativos, comentários apropriados e úteis e assim por diante não afetam o desempenho. E alguns - como usar enums em vez de strings - também têm benefícios de desempenho.

JacquesB
fonte
5

A sobrecarga da chamada de função não é importante na maioria dos casos.

No entanto, o maior ganho do código embutido é otimizar o novo código após a inclusão .

Por exemplo, se você chamar uma função com um argumento constante, o otimizador agora poderá dobrar esse argumento constantemente onde não podia antes de incluir a chamada. Se o argumento for um ponteiro de função (ou lambda), o otimizador também poderá incorporar as chamadas para esse lambda.

Esse é um grande motivo pelo qual as funções virtuais e os ponteiros de função não são atraentes, pois você não pode incorporá-los, a menos que o ponteiro de função real tenha sido constantemente dobrado até o site da chamada.

catraca arrepiante
fonte
5

Assumindo que o desempenho é importante para o seu programa e, de fato, tem muitas e muitas chamadas, o custo ainda pode ou não ser importante, dependendo do tipo de chamada.

Se a função chamada for pequena e o compilador puder incorporá-la, o custo será essencialmente zero. As implementações modernas de compiladores / idiomas têm JIT, otimizações de tempo de link e / ou sistemas de módulos projetados para maximizar a capacidade de incorporar funções quando for benéfico.

OTOH, há um custo não óbvio para chamadas de função: sua mera existência pode inibir otimizações do compilador antes e depois da chamada.

Se o compilador não puder raciocinar sobre o que a função chamada faz (por exemplo, é despacho virtual / dinâmico ou uma função em uma biblioteca dinâmica), pode ser necessário supor pessimisticamente que a função possa ter algum efeito colateral - lance uma exceção, modifique estado global ou altere qualquer memória vista através de ponteiros. O compilador pode precisar salvar valores temporários na memória traseira e lê-los novamente após a chamada. Ele não poderá reordenar as instruções em torno da chamada, portanto, poderá não conseguir vetorizar loops ou extrair computação redundante dos loops.

Por exemplo, se você chamar desnecessariamente uma função em cada iteração de loop:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

O compilador pode saber que é uma função pura e movê-la para fora do loop (em um caso terrível como este exemplo até corrige o algoritmo acidental O (n ^ 2) como O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

E então, talvez até reescreva o loop para processar elementos 4/8/16 de cada vez usando instruções gerais / SIMD.

Mas se você adicionar uma chamada a algum código opaco no loop, mesmo que a chamada não faça nada e seja super barata, o compilador deve assumir o pior - que a chamada acessará uma variável global que aponta para a mesma memória que a salteração seu conteúdo (mesmo que esteja constem sua função, pode não estar em constnenhum outro lugar), impossibilitando a otimização:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}
Kornel
fonte
3

Este artigo antigo pode responder à sua pergunta:

Guy Lewis Steele, Jr .. "Desmistificando o mito 'Chamada de procedimento caro', ou implementações de chamada de procedimento consideradas prejudiciais ou Lambda: o melhor GOTO". MIT AI Lab. Memorando AI Lab AIM-443. Outubro de 1977.

Abstrato:

O folclore afirma que as declarações GOTO são "baratas", enquanto as chamadas de procedimento são "caras". Esse mito é amplamente resultado de implementações de linguagem mal projetadas. O crescimento histórico desse mito é considerado. São discutidas idéias teóricas e uma implementação existente que desmascaram esse mito. É mostrado que o uso irrestrito de chamadas de procedimento permite uma grande liberdade de estilo. Em particular, qualquer fluxograma pode ser escrito como um programa "estruturado" sem a introdução de variáveis ​​extras. A dificuldade com a instrução GOTO e a chamada de procedimento é caracterizada como um conflito entre conceitos abstratos de programação e construções de linguagem concretas.

Alex Vong
fonte
12
Eu duvido muito que um artigo antigo responda à questão de se "os custos de chamadas de funções ainda são importantes nos compiladores modernos ".
Cody Grey #
6
@CodyGray Acho que a tecnologia do compilador deveria ter avançado desde 1977. Portanto, se as chamadas de função puderem ser feitas em 1977, poderemos fazê-lo agora. Então a resposta é não. Obviamente, isso pressupõe que você esteja usando uma implementação de linguagem decente que pode fazer coisas como a função embutida.
precisa saber é o seguinte
4
@AlexVong Confiar nas otimizações do compilador de 1977 é como confiar nas tendências dos preços das commodities na idade da pedra. Tudo mudou demais. Por exemplo, a multiplicação costumava ser substituída pelo acesso à memória como uma operação mais barata. Atualmente, é mais caro por um fator enorme. As chamadas de método virtual são relativamente muito mais caras do que costumavam ser (acesso à memória e previsões incorretas de ramificações), mas muitas vezes elas podem ser otimizadas e a chamada de método virtual pode ser incorporada (o Java faz isso o tempo todo), portanto, o custo é alto. exatamente zero. Não havia nada como isto em 1977.
maaartinus
3
Como outros já apontaram, não são apenas as mudanças na tecnologia do compilador que invalidaram a pesquisa antiga. Se os compiladores continuassem melhorando enquanto as microarquiteturas permanecessem praticamente inalteradas, as conclusões do artigo ainda seriam válidas. Mas isso não aconteceu. As microarquiteturas mudaram mais do que os compiladores. As coisas que costumavam ser rápidas agora são lentas, relativamente falando.
Cody cinzento
2
@AlexVong Para ser mais preciso sobre as alterações na CPU que tornam esse documento obsoleto: Em 1977, um acesso à memória principal era um único ciclo da CPU. Hoje, mesmo um simples acesso ao cache L1 (!) Tem uma latência de 3 a 4 ciclos. Agora, as chamadas de função são bastante pesadas nos acessos à memória (criação de quadro de pilha, economia de endereço de retorno, economia de registradores para variáveis ​​locais), o que facilmente leva os custos de uma única chamada de função a 20 ou mais ciclos. Se sua função apenas reorganizar seus argumentos e talvez adicionar outro argumento constante a ser passado para uma chamada, isso significa quase 100% de sobrecarga.
cmaster
3
  • No C ++, cuidado com o design de chamadas de função que copiam argumentos, o padrão é "passar por valor". A sobrecarga da chamada de função devido ao salvamento de registros e outras coisas relacionadas ao quadro da pilha pode ser sobrecarregada por uma cópia não intencional (e potencialmente muito cara) de um objeto.

  • Há otimizações relacionadas ao quadro de pilha que você deve investigar antes de desistir de código altamente fatorado.

  • Na maioria das vezes, quando tive que lidar com um programa lento, descobri que fazer alterações algorítmicas produzia acelerações muito maiores do que as chamadas de função embutidas. Por exemplo: outro engenheiro refez um analisador que preencheu uma estrutura de mapa de mapas. Como parte disso, ele removeu um índice em cache de um mapa para um associado logicamente. Essa foi uma boa jogada de robustez do código, no entanto, tornou o programa inutilizável devido a um fator de desaceleração de 100 devido à realização de uma pesquisa de hash para todos os acessos futuros em comparação ao uso do índice armazenado. A criação de perfil mostrou que a maior parte do tempo era gasta na função de hash.

user2543191
fonte
4
O primeiro conselho é um pouco antigo. Desde o C ++ 11, a mudança é possível. Em particular, para funções que precisam modificar seus argumentos internamente, pegar um argumento por valor e modificá-lo no local pode ser a opção mais eficiente.
MSalters
@ MSalters: Eu acho que você confundiu "em particular" com "além disso" ou algo assim. A decisão de passar cópias ou referências existia antes do C ++ 11 (embora eu saiba que você conhece).
phresnel
@phresnel: Eu acho que entendi direito. O caso específico ao qual estou me referindo é o caso em que você cria um temporário no chamador, move -o para um argumento e modifica-o no chamado. Isto não era possível antes C ++ 11, como C ++ 03 pode não / não irão ligar-se uma referência não-const a um temporário ..
MSalters
@MSalters: Então eu entendi mal o seu comentário na primeira leitura. Pareceu-me que você estava sugerindo que, antes do C ++ 11, passar por valor não era algo que alguém faria se quisesse modificar o valor passado.
phresnel
O advento do 'movimento' ajuda mais significativamente no retorno de objetos que são mais convenientemente construídos na função do que fora e sendo passados ​​por referência. Antes disso, o retorno de um objeto de uma função invocava uma cópia, geralmente uma movimentação cara. Isso não lida com argumentos de função. Eu cuidadosamente coloquei a palavra "design" no comentário, pois é preciso dar explicitamente permissão ao compilador para 'mover' os argumentos da função (sintaxe &&). Eu adquiri o hábito de 'excluir' construtores de cópias para identificar lugares em que isso é valioso.
user2543191
2

Sim, uma previsão de ramificação perdida é mais cara no hardware moderno do que há décadas atrás, mas os compiladores ficaram muito mais inteligentes ao otimizar isso.

Como exemplo, considere Java. À primeira vista, a sobrecarga de chamada de função deve ser particularmente dominante neste idioma:

  • pequenas funções são generalizadas devido à convenção JavaBean
  • funções padrão para virtual e geralmente são
  • a unidade de compilação é a classe; o tempo de execução suporta o carregamento de novas classes a qualquer momento, incluindo subclasses que substituem métodos anteriormente monomórficos

Horrorizado com essas práticas, o programador médio de C preveria que o Java deveria ser pelo menos uma ordem de magnitude mais lenta que C. E há 20 anos, ele estaria certo. No entanto, os benchmarks modernos colocam o código Java idiomático dentro de alguns por cento do código C equivalente. Como isso é possível?

Uma razão é que a função embutida das JVMs modernas chama normalmente. Faz isso usando inlining especulativo:

  1. O código recém carregado é executado sem otimização. Durante esse estágio, para cada site de chamada, a JVM monitora quais métodos foram realmente chamados.
  2. Depois que o código é identificado como hotspot de desempenho, o tempo de execução usa essas estatísticas para identificar o caminho de execução mais provável e destaca esse código, prefixando-o com um ramo condicional, caso a otimização especulativa não se aplique.

Ou seja, o código:

int x = point.getX();

é reescrito para

if (point.class != Point) GOTO interpreter;
x = point.x;

E, é claro, o tempo de execução é inteligente o suficiente para passar para cima nessa verificação de tipo, desde que o ponto não seja atribuído, ou excluí-la se o tipo for conhecido pelo código de chamada.

Em resumo, se até o Java gerencia a inserção automática de métodos, não há razão inerente para que um compilador não suporte a inserção automática, e todos os motivos para fazê-lo, porque a inserção é altamente benéfica para os processadores modernos. Portanto, dificilmente posso imaginar qualquer compilador convencional moderno ignorando essas estratégias básicas de otimização e presumiria um compilador capaz disso, a menos que provado o contrário.

meriton - em greve
fonte
4
“Não existe uma razão inerente para que um compilador não possa suportar inlining automático” - existe. Você falou sobre a compilação JIT, que equivale a código auto-modificável (que um sistema operacional pode impedir por segurança) e a capacidade de fazer otimização automática de programa completo guiada por perfil. Um compilador AOT para um idioma que permite a vinculação dinâmica não sabe o suficiente para descirtualizar e alinhar qualquer chamada. OTOH: um compilador AOT tem tempo para otimizar tudo o que pode, um compilador JIT só tem tempo para se concentrar em otimizações baratas em pontos de acesso. Na maioria dos casos, isso deixa o JIT em uma pequena desvantagem.
amon
2
Diga-me um sistema operacional que impede a execução do Google Chrome "por segurança" (a V8 compila JavaScript para código nativo em tempo de execução). Além disso, querer incorporar o AOT não é um motivo inerente (não é determinado pelo idioma, mas pela arquitetura que você escolhe para o seu compilador) e, embora o vínculo dinâmico iniba o AOT inlining nas unidades de compilação, ele não inibe o inlining na compilação onde a maioria das chamadas ocorre. De fato, inlining útil é sem dúvida mais fácil em uma linguagem que usa links dinâmicos menos excessivamente que Java.
meriton - em greve 10/09/19
4
Notavelmente, o iOS impede o JIT para aplicativos não privilegiados. O Chrome ou o Firefox precisam usar a exibição da Web fornecida pela Apple em vez de seus próprios mecanismos. Bom ponto, porém, que AOT vs. JIT é uma opção no nível da implementação, não no nível do idioma.
amon
Os sistemas operacionais Windows 10 S e console de videogame também tendem a bloquear mecanismos JIT de terceiros.
Damian Yerrick
2

Como outros dizem, você deve medir o desempenho do seu programa primeiro e provavelmente não encontrará nenhuma diferença na prática.

Ainda assim, do nível conceitual, pensei em esclarecer algumas coisas que estão conflitantes em sua pergunta. Em primeiro lugar, você pergunta:

Os custos das chamadas de função ainda são importantes nos compiladores modernos?

Observe as palavras-chave "função" e "compiladores". Sua cotação é sutilmente diferente:

Lembre-se de que o custo de uma chamada de método pode ser significativo, dependendo do idioma.

Trata-se de métodos , no sentido orientado a objetos.

Enquanto "função" e "método" são frequentemente usados ​​de forma intercambiável, existem diferenças no que diz respeito ao custo (do que você está perguntando) e quando se trata de compilação (que é o contexto que você forneceu).

Em particular, precisamos saber sobre despacho estático versus despacho dinâmico . Ignorarei otimizações no momento.

Em uma linguagem como C, geralmente chamamos funções com despacho estático . Por exemplo:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Quando o compilador vê a chamada foo(y), ele sabe a qual função esse foonome está se referindo, para que o programa de saída possa ir direto para a foofunção, o que é bastante barato. É isso que despacho estático significa.

A alternativa é o envio dinâmico , onde o compilador não sabe qual função está sendo chamada. Como exemplo, aqui está um código Haskell (já que o equivalente em C seria confuso!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Aqui a barfunção está chamando seu argumento f, que pode ser qualquer coisa. Portanto, o compilador não pode simplesmente compilar barcom uma instrução de salto rápido, porque não sabe para onde ir. Em vez disso, o código para o qual geramos barfará a desreferência fpara descobrir para qual função está apontando e depois pulará para ela. É isso que despacho dinâmico significa.

Ambos os exemplos são para funções . Você mencionou métodos , que podem ser considerados como um estilo particular de função despachada dinamicamente. Por exemplo, aqui estão alguns Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

A y.foo()chamada usa despacho dinâmico, pois está pesquisando o valor da foopropriedade no yobjeto e chamando o que encontrar; ele não sabe que yterá classe Aou que a Aclasse contém um foométodo; portanto, não podemos simplesmente pular direto para ele.

OK, essa é a ideia básica. Observe que o envio estático é mais rápido que o envio dinâmico, independentemente de compilarmos ou interpretarmos; tudo o resto é igual. A desreferenciação incorre em um custo extra de qualquer maneira.

Então, como isso afeta os compiladores modernos e otimizadores?

A primeira coisa a observar é que o envio estático pode ser otimizado com mais intensidade: quando sabemos para qual função estamos pulando, podemos fazer coisas como embutir. Com o envio dinâmico, não sabemos se estamos pulando até o tempo de execução, portanto não há muita otimização que possamos fazer.

Em segundo lugar, é possível em algumas línguas inferir para onde alguns despachos dinâmicos terminarão pulando e, portanto, otimizá-los em despacho estático. Isso nos permite realizar outras otimizações, como inlining etc.

No exemplo acima do Python, essa inferência é bastante inútil, já que o Python permite que outro código substitua classes e propriedades, por isso é difícil inferir muito do que é válido em todos os casos.

Se nosso idioma nos permitir impor mais restrições, por exemplo, limitando ya classe Ausando uma anotação, poderíamos usar essas informações para inferir a função de destino. Em linguagens com subclassificação (que é quase todas as linguagens com classes!), Isso na verdade não é suficiente, uma vez que ypode realmente ter uma (sub) classe diferente; portanto, precisamos de informações adicionais, como as finalanotações de Java, para saber exatamente qual função será chamada.

Haskell não é uma linguagem OO, mas podemos inferir o valor de fpor inlining bar(que é estaticamente despachado) em main, substituindo foopara y. Como o destino de fooin mainé estaticamente conhecido, a chamada se torna estaticamente despachada e provavelmente será incorporada e otimizada completamente (como essas funções são pequenas, é mais provável que o compilador as incline; embora não possamos contar com isso em geral )

Portanto, o custo se resume a:

  • O idioma despacha sua chamada estática ou dinamicamente?
  • Se for o último, a linguagem permite que a implementação infere o destino usando outras informações (por exemplo, tipos, classes, anotações, inlining etc.)?
  • Com que agressividade o despacho estático (inferido ou não) pode ser otimizado?

Se você estiver usando uma linguagem "muito dinâmica", com muito envio dinâmico e poucas garantias disponíveis para o compilador, todas as chamadas terão um custo. Se você estiver usando uma linguagem "muito estática", um compilador maduro produzirá código muito rápido. Se você estiver no meio, isso pode depender do seu estilo de codificação e do quão inteligente é a implementação.

Warbo
fonte
1
Discordo que chamar um fechamento (ou algum ponteiro de função ) - como seu exemplo de Haskell - é despacho dinâmico. o envio dinâmico envolve alguma computação (por exemplo, usando alguma tabela ) para obter esse fechamento, portanto é mais caro do que as chamadas indiretas. Caso contrário, boa resposta.
Basile Starynkevitch
2

Lembre-se de que o custo de uma chamada de método pode ser significativo, dependendo do idioma. Quase sempre há uma troca entre escrever código legível e escrever código de desempenho.

Infelizmente, isso é altamente dependente de:

  • a cadeia de ferramentas do compilador, incluindo o JIT, se houver,
  • o domínio.

Primeiro de tudo, a primeira lei da otimização de desempenho é o primeiro perfil . Existem muitos domínios nos quais o desempenho da parte do software é irrelevante para o desempenho de toda a pilha: chamadas de banco de dados, operações de rede, operações de SO, ...

Isso significa que o desempenho do software é completamente irrelevante, mesmo que não melhore a latência. A otimização do software pode resultar em economia de energia e hardware (ou economia de bateria para aplicativos móveis), o que pode ser importante.

No entanto, esses NÃO costumam ter problemas oculares, e muitas vezes as melhorias algorítmicas superam as micro otimizações por uma grande margem.

Portanto, antes de otimizar, você precisa entender para o que está otimizando ... e se vale a pena.


Agora, com relação ao desempenho puro do software, ele varia muito entre as cadeias de ferramentas.

Existem dois custos para uma chamada de função:

  • o custo do tempo de execução,
  • o custo do tempo de compilação.

O custo do tempo de execução é bastante óbvio; para executar uma chamada de função, é necessária uma certa quantidade de trabalho. Usando C no x86, por exemplo, uma chamada de função exigirá (1) espalhar registros na pilha, (2) enviar argumentos para os registradores, executar a chamada e depois (3) restaurar os registros da pilha. Veja este resumo das convenções de chamada para ver o trabalho envolvido .

Esse derramamento / restauração de registro leva uma quantidade não trivial de vezes (dezenas de ciclos da CPU).

Geralmente, espera-se que esse custo seja trivial em comparação com o custo real da execução da função, no entanto, alguns padrões são contraproducentes aqui: getters, funções protegidas por uma condição simples, etc.

Além dos intérpretes , um programador espera, portanto, que seu compilador ou JIT otimize as chamadas de função desnecessárias; embora essa esperança às vezes não dê frutos. Porque otimizadores não são mágicos.

Um otimizador pode detectar que uma chamada de função é trivial e incorporar a chamada: essencialmente, copie / cole o corpo da função no site da chamada. Isso nem sempre é uma boa otimização (pode induzir inchaço), mas geralmente vale a pena, porque o inlining expõe o contexto , e o contexto permite mais otimizações.

Um exemplo típico é:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Se funcestiver embutido, o otimizador perceberá que a ramificação nunca é tomada e otimizará callpara void call() {}.

Nesse sentido, as chamadas de função, ocultando informações do otimizador (se ainda não estiverem incorporadas), podem inibir certas otimizações. As chamadas de função virtual são especialmente culpadas disso, porque a destirtualização (provar que função é chamada em última instância no tempo de execução) nem sempre é fácil.


Em conclusão, meu conselho é escrever com clareza primeiro, evitando a pessimização algorítmica prematura (complexidade cúbica ou piores mordidas rapidamente) e depois otimizar apenas o que precisa ser otimizado.

Matthieu M.
fonte
1

"Lembre-se de que o custo de uma chamada de método pode ser significativo, dependendo do idioma. Quase sempre há uma troca entre escrever código legível e escrever código de desempenho".

Em que condições essa declaração citada ainda é válida hoje em dia, dada a rica indústria de compiladores modernos com bom desempenho?

Eu só vou dizer nunca. Acredito que a citação seja imprudente para apenas jogar lá fora.

É claro que não estou falando a verdade completa, mas não me importo em ser sincero tanto assim. É como naquele filme de Matrix, eu esqueci se era 1 ou 2 ou 3 - acho que foi aquele com a atriz italiana sexy com os grandes melões (eu realmente não gostei de nenhum, exceto o primeiro), quando oracle lady disse a Keanu Reeves: "Acabei de lhe dizer o que você precisava ouvir", ou algo nesse sentido, é isso que quero fazer agora.

Programadores não precisam ouvir isso. Se eles tiverem experiência com os criadores de perfil em suas mãos e a cotação for um pouco aplicável aos seus compiladores, eles já saberão disso e aprenderão isso da maneira correta, desde que compreendam sua saída de criação de perfil e por que determinadas chamadas em folha são pontos de acesso, através da medição. Se eles não são experientes e nunca criaram um perfil de seu código, esta é a última coisa que eles precisam ouvir, que devem começar a comprometer supersticiosamente a forma como escrevem o código até o ponto de incluir tudo antes mesmo de identificar pontos críticos na esperança de que isso aconteça. tornar-se mais eficiente.

Enfim, para uma resposta mais precisa, depende. Algumas das condições já estão listadas entre as boas respostas. As condições possíveis para a escolha de um idioma já são enormes, como o C ++, que teria que ser despachado dinamicamente em chamadas virtuais e quando ele pode ser otimizado e sob quais compiladores e até vinculadores, e que já justifica uma resposta detalhada e muito menos tentar para enfrentar as condições em todos os idiomas e compiladores possíveis. Mas vou acrescentar, "quem se importa?" porque, mesmo trabalhando em áreas críticas de desempenho como raytracing, a última coisa que começarei a fazer é abordar os métodos antes de fazer qualquer medição.

Eu acredito que algumas pessoas ficam muito zelosas ao sugerir que você nunca deve fazer micro-otimizações antes da medição. Se a otimização para a localidade das referências conta como uma micro-otimização, geralmente começo a aplicar essas otimizações logo no início com uma mentalidade de design orientada a dados em áreas que eu sei com certeza serão críticas ao desempenho (código de rastreamento de raios, por exemplo), porque, caso contrário, sei que terei que reescrever grandes seções logo depois de trabalhar nesses domínios por anos. A otimização da representação de dados para acertos no cache geralmente pode ter o mesmo tipo de aprimoramento de desempenho que os aprimoramentos algorítmicos, a menos que falemos do tempo quadrático ao linear.

Mas nunca vi um bom motivo para começar a incluir antes das medições, especialmente porque os criadores de perfil são decentes em revelar o que pode se beneficiar, mas não em revelar o que pode ser beneficiado por não estarem em linha (e não em linha pode realmente tornar o código mais rápido se o chamada de função sem linha é um caso raro, melhorando a localidade de referência para o icache para código quente e às vezes até permitindo que os otimizadores executem um trabalho melhor no caminho de execução comum do caso).


fonte