Em C ++, por que e como as funções virtuais são mais lentas?

38

Alguém pode explicar em detalhes como exatamente a tabela virtual funciona e quais ponteiros estão associados quando as funções virtuais são chamadas.

Se eles forem realmente mais lentos, você pode mostrar que o tempo que a função virtual leva para executar é mais do que os métodos de classe normais? É fácil perder a noção de como / o que está acontecendo sem ver algum código.

MdT
fonte
5
Procurar a chamada de método correta em uma vtable obviamente levará mais tempo do que chamar o método diretamente, pois há mais a fazer. Quanto tempo mais, ou se esse tempo adicional é significativo no contexto do seu próprio programa, é outra questão. pt.wikipedia.org/wiki/Virtual_method_table
Robert Harvey
10
Mais lento do que exatamente? Eu vi código que teve uma implementação lenta e quebrada do comportamento dinâmico com muitas instruções de opção apenas porque algum programador ouviu que as funções virtuais são lentas.
Christopher Creutzig 22/03
7
Muitas vezes, não é o fato de as chamadas virtuais serem lentas, mas o compilador não tem capacidade de incorporá-las.
Kevin Hsu
4
@ Kevin Hsu: sim, é isso absolutamente. Quase sempre que alguém lhe diz que conseguiu acelerar a eliminação de algumas "despesas gerais de chamadas de funções virtuais", se você observar de onde veio toda a aceleração, serão de otimizações que agora são possíveis porque o compilador não pôde otimizar a chamada indeterminada anteriormente.
timday 23/03
7
Mesmo uma pessoa que pode ler o código do assembly não pode prever com precisão sua sobrecarga na execução real da CPU. Os fabricantes de CPU baseados em desktops investiram em décadas de pesquisa não apenas na previsão de ramificação, mas também na previsão de valor e na execução especulativa pelo principal motivo de mascarar a latência das funções virtuais. Por quê? Porque os SOs e softwares de desktop os usam muito. (Eu não diria o mesmo sobre CPUs móveis.)
rwong

Respostas:

55

Os métodos virtuais são comumente implementados por meio das chamadas tabelas de métodos virtuais (vtable for short), nas quais os ponteiros de função são armazenados. Isso adiciona indireção à chamada real (precisa buscar o endereço da função a ser chamada na vtable e depois chamá-la - em vez de apenas chamá-la imediatamente). Obviamente, isso leva algum tempo e um pouco mais de código.

No entanto, não é necessariamente a principal causa de lentidão. O verdadeiro problema é que o compilador (geralmente / geralmente) não pode saber qual função será chamada. Portanto, não pode incorporá-lo ou executar outras otimizações. Isso por si só pode adicionar uma dúzia de instruções inúteis (preparar registros, chamar e restaurar o estado posteriormente) e pode inibir outras otimizações aparentemente não relacionadas. Além disso, se você ramificar como louco chamando muitas implementações diferentes, sofrerá os mesmos hits de ramificar como louco por outros meios: o preditor de cache e ramificação não o ajudará, os ramos levarão mais tempo do que um perfeitamente previsível ramo.

Grande, mas : esses resultados de desempenho geralmente são muito pequenos para importar. Vale a pena considerar se você deseja criar um código de alto desempenho e considerar adicionar uma função virtual que seria chamada com frequência alarmante. No entanto, também ter em mente que a substituição de chamadas de funções virtuais com outros meios de ramificação ( if .. else, switch, ponteiros de função, etc.) não vai resolver a questão fundamental - ele pode muito bem ser mais lento. O problema (se é que existe) não são funções virtuais, mas indiretas (desnecessárias).

Editar: a diferença nas instruções de chamada é descrita em outras respostas. Basicamente, o código para uma chamada estática ("normal") é:

  • Copie alguns registros na pilha, para permitir que a função chamada use esses registros.
  • Copie os argumentos em locais predefinidos, para que a função chamada possa encontrá-los, independentemente de onde é chamada.
  • Empurre o endereço de retorno.
  • Ramifique / salte para o código da função, que é um endereço em tempo de compilação e, portanto, codificado no binário pelo compilador / vinculador.
  • Obtenha o valor de retorno de um local predefinido e restaure os registros que queremos usar.

Uma chamada virtual faz exatamente a mesma coisa, exceto que o endereço da função não é conhecido no momento da compilação. Em vez disso, algumas instruções ...

  • Obtenha o ponteiro vtable, que aponta para uma matriz de ponteiros de função (endereços de função), um para cada função virtual, do objeto.
  • Obtenha o endereço de função correto da vtable em um registrador (o índice em que o endereço de função correto está armazenado é decidido em tempo de compilação).
  • Vá para o endereço desse registro, em vez de saltar para um endereço codificado.

Quanto aos ramos: Um ramo é qualquer coisa que salta para outra instrução em vez de apenas deixar a próxima instrução executar. Isto inclui if, switch, partes de vários loops, chamadas de função, etc, e às vezes os implementos compilador coisas que não parecem ramo de uma forma que realmente precisa de um ramo sob o capô. Consulte Por que o processamento de uma matriz classificada é mais rápido que uma matriz não classificada? por que isso pode ser lento, o que as CPUs fazem para combater essa desaceleração e como isso não é uma solução definitiva.

Comunidade
fonte
6
@ JörgWMittag todos eles são coisas intérprete, e ainda mais lento é que o código binário gerado por compiladores C ++
Sam
13
@ JörgWMittag Essas otimizações existem principalmente para tornar a ligação indireta / tardia (quase) gratuita quando não é necessária , porque nesses idiomas todas as chamadas são tecnicamente atrasadas. Se você realmente chamar muitos métodos virtuais diferentes de um local em pouco tempo, essas otimizações não ajudam ou prejudicam ativamente (crie muito código por nada). Caras C ++ não estão muito interessados nessas otimizações porque eles estão em uma situação muito diferente ...
10
@ JörgWMittag ... Os caras do C ++ não estão muito interessados ​​nessas otimizações porque estão em uma situação muito diferente: a maneira de vtable compilada pela AOT já é bastante rápida, poucas chamadas são realmente virtuais, muitos casos de polimorfismo são precoces. (via modelos) e, portanto, alterável para otimização AOT. Por fim, fazer essas otimizações de forma adaptativa (em vez de apenas especular em tempo de compilação) requer a geração de código em tempo de execução, o que gera muita dor de cabeça. Os compiladores JIT já resolveram esses problemas por outros motivos, então eles não se importam, mas os compiladores AOT querem evitá-lo.
3
ótima resposta, +1. Uma coisa a ser observada, porém, é que às vezes os resultados da ramificação são conhecidos em tempo de compilação, por exemplo, quando você escreve classes de estrutura que precisam oferecer suporte a usos diferentes, mas quando o código do aplicativo interage com essas classes, o uso específico já é conhecido. Nesse caso, a alternativa para funções virtuais pode ser modelos C ++. Um bom exemplo seria o CRTP, que emula o comportamento da função virtual sem nenhuma vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM:
3
@ James Você tem razão. O que tentei dizer é: qualquer indireção tem os mesmos problemas, não é nada específico virtual.
23

Aqui estão alguns códigos desmontados reais de uma chamada de função virtual e uma chamada não virtual, respectivamente:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

Você pode ver que a chamada virtual requer três instruções adicionais para procurar o endereço correto, enquanto o endereço da chamada não virtual pode ser compilado.

No entanto, observe que na maioria das vezes esse tempo extra de pesquisa pode ser considerado insignificante. Em situações em que o tempo de pesquisa seria significativo, como em um loop, o valor geralmente pode ser armazenado em cache executando as três primeiras instruções antes do loop.

A outra situação em que o tempo de pesquisa se torna significativo é se você tiver uma coleção de objetos e estiver repetindo a chamada de uma função virtual em cada um deles. No entanto, nesse caso, você precisará de alguns meios para selecionar qual função chamar de qualquer maneira, e uma consulta à tabela virtual é o melhor meio possível. De fato, como o código de pesquisa da vtable é tão amplamente usado, ele é altamente otimizado, portanto, tentar contorná-lo manualmente tem uma boa chance de resultar em pior desempenho.

Karl Bielefeldt
fonte
1
O que se deve entender é que a consulta à vtable e a chamada indireta terão quase todos os casos um impacto insignificante no tempo total de execução do método que está sendo chamado.
John R. Strohm 23/03
12
@ JohnR.Strohm insignificante de um homem é gargalo de outro homem
James
1
-0x8(%rbp). oh meu ... essa sintaxe da AT&T.
Abyx
" Três instruções adicionais " não, apenas dois: carregar o vptr e carregar o ponteiro de função
curiousguy
@curiousguy, são de fato três instruções adicionais. Você esqueceu que um método virtual é sempre chamado em um ponteiro , portanto, você deve primeiro carregar o ponteiro em um registro. Para resumir, a primeira etapa é carregar o endereço que a variável ponteiro contém no registro% rax; em seguida, de acordo com o endereço no registro, carregue o vtpr nesse endereço para registrar% rax e, em seguida, de acordo com este endereço no diretório registre, carregue o endereço do método a ser chamado em% rax e, em seguida, chameq *% rax !.
Gab是好人
18

Mais lento que o que ?

As funções virtuais resolvem um problema que não pode ser resolvido por chamadas diretas de função. Em geral, você pode comparar apenas dois programas que calculam a mesma coisa. "Esse traçador de raios é mais rápido que o compilador" não faz sentido, e esse princípio generaliza até coisas pequenas, como funções individuais ou construções de linguagem de programação.

Se você não usar uma função virtual para alternar dinamicamente para um pedaço de código com base em um dado, como o tipo de um objeto, precisará usar outra coisa, como uma switchinstrução para realizar a mesma coisa. Essa outra coisa tem suas próprias despesas gerais, além de implicações na organização do programa que influenciam sua capacidade de manutenção e desempenho global.

Observe que, em C ++, chamadas para funções virtuais nem sempre são dinâmicas. Quando chamadas são feitas em um objeto cujo tipo exato é conhecido (porque o objeto não é um ponteiro ou referência, ou porque seu tipo pode ser inferido estaticamente), as chamadas são apenas chamadas regulares de função de membro. Isso não significa apenas que não há despesas gerais de envio, mas também que essas chamadas podem ser incorporadas da mesma maneira que as chamadas comuns.

Em outras palavras, seu compilador C ++ pode funcionar quando as funções virtuais não exigem expedição virtual, portanto, geralmente não há motivo para se preocupar com o desempenho delas em relação às funções não virtuais.

Novo: Além disso, não devemos esquecer as bibliotecas compartilhadas. Se você estiver usando uma classe que está em uma biblioteca compartilhada, a chamada para uma função de membro comum não será simplesmente uma sequência de instruções agradável callq 0x4007aa. Ele precisa passar por algumas dificuldades, como indiretamente, através de uma "tabela de links de programas" ou alguma estrutura desse tipo. Portanto, a indireção da biblioteca compartilhada pode nivelar um pouco (se não completamente) a diferença de custo entre a chamada virtual (verdadeiramente indireta) e uma chamada direta. Portanto, o raciocínio sobre as trocas de funções virtuais deve levar em consideração como o programa é construído: se a classe do objeto de destino está monoliticamente vinculada ao programa que está fazendo a chamada.

Kaz
fonte
4
"Mais devagar que o quê?" - se você virtualiza um método que não precisa ser, possui um bom material de comparação.
tdammers
2
Obrigado por apontar que as chamadas para funções virtuais nem sempre são dinâmicas. Todas as outras respostas aqui fazem parecer que declarar uma função virtual significa um impacto automático no desempenho, independentemente da circunstância.
21715 Syndog
12

porque uma chamada virtual é equivalente a

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

onde, com uma função não virtual, o compilador pode dobrar constantemente a primeira linha, isso é uma desreferência, uma adição e uma chamada dinâmica transformada em apenas uma chamada estática

isso também permite incorporar a função (com todas as conseqüências de otimização devidas)

catraca arrepiante
fonte