En C ++, ¿por qué y cómo son más lentas las funciones virtuales?


38

¿Alguien puede explicar en detalle cómo funciona exactamente la tabla virtual y qué punteros están asociados cuando se llaman funciones virtuales?

Si en realidad son más lentos, ¿puede mostrar que el tiempo que tarda la función virtual en ejecutarse es más que los métodos de clase normales? Es fácil perder la noción de cómo / qué está sucediendo sin ver algún código.


55
Obviamente, buscar la llamada al método correcto desde una vtable llevará más tiempo que llamar al método directamente, ya que hay más por hacer. Cuánto más, o si ese tiempo adicional es significativo dentro del contexto de su propio programa, es otra cuestión. en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey

10
¿Más lento que exactamente? He visto código que tenía una implementación lenta y rota de comportamiento dinámico con muchas instrucciones de cambio solo porque algún programador había escuchado que las funciones virtuales son lentas.
Christopher Creutzig

77
Muchas veces, no es que las llamadas virtuales en sí mismas sean lentas, sino que el compilador no tiene la capacidad de incorporarlas.
Kevin Hsu

44
@ Kevin Hsu: sí, esto es absolutamente. Casi cada vez que alguien le dice que se aceleró al eliminar alguna "sobrecarga de llamadas de función virtual", si observa de dónde proviene realmente la aceleración será de optimizaciones que ahora son posibles porque el compilador no pudo optimizar la llamada indeterminada previamente.
Timday

77
Incluso una persona que puede leer el código de ensamblado no puede predecir con precisión su sobrecarga en la ejecución real de la CPU. Los fabricantes de CPU de escritorio han invertido en décadas de investigación no solo en la predicción de ramas, sino también en la predicción de valores y la ejecución especulativa por la razón principal de enmascarar la latencia de las funciones virtuales. ¿Por qué? Porque los sistemas operativos de escritorio y el software los usan mucho. (No diría lo mismo sobre las CPU móviles.)
rwong el

Respuestas:


55

Los métodos virtuales se implementan comúnmente a través de las llamadas tablas de métodos virtuales (vtable para abreviar), en las que se almacenan los punteros de función. Esto agrega indirección a la llamada real (debe buscar la dirección de la función para llamar desde la tabla virtual, luego llamarla, en lugar de simplemente llamarla inmediatamente). Por supuesto, esto lleva algo de tiempo y algo más de código.

Sin embargo, no es necesariamente la causa principal de la lentitud. El verdadero problema es que el compilador (generalmente / usualmente) no puede saber qué función se llamará. Por lo tanto, no puede alinearlo ni realizar ninguna otra optimización de este tipo. Esto solo podría agregar una docena de instrucciones sin sentido (preparar registros, llamar y luego restaurar el estado), y podría inhibir otras optimizaciones aparentemente no relacionadas. Además, si se ramifica como loco al llamar a muchas implementaciones diferentes, sufre los mismos golpes que sufriría si se ramifica como loco por otros medios: el caché y el predictor de ramificación no lo ayudarán, las ramificaciones tardarán más de lo que es perfectamente predecible rama.

Grande pero : estos éxitos de rendimiento suelen ser demasiado pequeños para importar. Vale la pena considerar si desea crear un código de alto rendimiento y considerar agregar una función virtual que se llamaría con una frecuencia alarmante. Sin embargo, también tenga en cuenta que reemplazar las llamadas a funciones virtuales con otros medios de ramificación ( if .. else, switchpunteros de función, etc.) no resolverá el problema fundamental, puede muy bien ser más lento. El problema (si es que existe) no son funciones virtuales sino indirección (innecesaria).

Editar: la diferencia en las instrucciones de la llamada se describe en otras respuestas. Básicamente, el código para una llamada estática ("normal") es:

  • Copie algunos registros en la pila, para permitir que la función llamada use esos registros.
  • Copie los argumentos en ubicaciones predefinidas, para que la función llamada pueda encontrarlos independientemente de dónde se llame.
  • Empuje la dirección del remitente.
  • Ramifica / salta al código de la función, que es una dirección de tiempo de compilación y, por lo tanto, está codificada en el binario por el compilador / enlazador.
  • Obtenga el valor de retorno de una ubicación predefinida y restaure los registros que queremos usar.

Una llamada virtual hace exactamente lo mismo, excepto que la dirección de la función no se conoce en tiempo de compilación. En cambio, un par de instrucciones ...

  • Obtenga el puntero vtable, que apunta a una matriz de punteros de función (direcciones de función), uno para cada función virtual, del objeto.
  • Obtenga la dirección de función correcta de vtable en un registro (el índice donde se almacena la dirección de función correcta se decide en tiempo de compilación).
  • Salte a la dirección en ese registro, en lugar de saltar a una dirección codificada.

En cuanto a las ramas: una rama es cualquier cosa que salta a otra instrucción en lugar de simplemente dejar que se ejecute la siguiente instrucción. Esto incluye if, switch, partes de varios bucles, llamadas a funciones, etc. ya veces los implementos compilador cosas que no parecen rama de una manera que realmente necesita una rama bajo el capó. Consulte ¿Por qué el procesamiento de una matriz ordenada es más rápido que una matriz sin clasificar? por qué esto puede ser lento, qué hacen las CPU para contrarrestar esta desaceleración y cómo esto no es una cura para todo.


66
@ JörgWMittag todos son material de intérprete, y aún son más lentos que el código binario generado por los compiladores de C ++
Sam

13
@ JörgWMittag Estas optimizaciones existen principalmente para hacer que la indirección / enlace tardío (casi) libre cuando no sea necesario , porque en esos idiomas cada llamada está técnicamente limitada. Si realmente llama a muchos métodos virtuales diferentes desde un solo lugar durante un breve período de tiempo, estas optimizaciones no ayudan ni perjudican activamente (crear mucho código para nada). Los chicos de C ++ no están muy interesados ​​en esas optimizaciones porque están en una situación muy diferente ...

10
@ JörgWMittag ... Los chicos de C ++ no están muy interesados ​​en esas optimizaciones porque se encuentran en una situación muy diferente: la forma vtable compilada por AOT ya es bastante rápida, muy pocas llamadas son realmente virtuales, muchos casos de polimorfismo son tempranos enlazado (a través de plantillas) y, por lo tanto, modificable para la optimización AOT. Finalmente, hacer estas optimizaciones de forma adaptativa (en lugar de especular en tiempo de compilación) requiere la generación de código en tiempo de ejecución, lo que introduce toneladas de dolor de cabeza. Los compiladores JIT ya han resuelto esos problemas por otras razones, por lo que no les importa, pero los compiladores AOT quieren evitarlo.

3
gran respuesta, +1. Sin embargo, una cosa a tener en cuenta es que a veces los resultados de la ramificación se conocen en el momento de la compilación, por ejemplo, cuando escribe clases de marco que necesitan admitir diferentes usos, pero una vez que el código de la aplicación interactúa con esas clases, el uso específico ya se conoce. En este caso, la alternativa a las funciones virtuales, podrían ser plantillas C ++. Un buen ejemplo sería CRTP, que emula el comportamiento de la función virtual sin vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@ James Tienes un punto. Lo que intenté decir es: cualquier indirección tiene los mismos problemas, no es nada específico virtual.

23

Aquí hay un código desmontado real de una llamada de función virtual y una llamada no virtual, respectivamente:

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

callq  0x4007aa

Puede ver que la llamada virtual requiere tres instrucciones adicionales para buscar la dirección correcta, mientras que la dirección de la llamada no virtual se puede compilar.

Sin embargo, tenga en cuenta que la mayoría de las veces ese tiempo de búsqueda adicional puede considerarse insignificante. En situaciones en las que el tiempo de búsqueda sería significativo, como en un ciclo, el valor generalmente se puede almacenar en caché haciendo las tres primeras instrucciones antes del ciclo.

La otra situación en la que el tiempo de búsqueda se vuelve significativo es si tiene una colección de objetos y está realizando una llamada virtual a una función virtual en cada uno de ellos. Sin embargo, en ese caso, necesitará algunos medios para seleccionar qué función llamar de todos modos, y una búsqueda de tabla virtual es un medio tan bueno como cualquier otro. De hecho, dado que el código de búsqueda de vtable se usa tanto, está muy optimizado, por lo que tratar de evitarlo manualmente tiene una buena posibilidad de resultar en un peor rendimiento.


1
Lo que hay que entender es que la búsqueda de vtable y la llamada indirecta en casi todos los casos tendrán un impacto insignificante en el tiempo total de ejecución del método que se llama.
John R. Strohm el

12
@ JohnR.Strohm Un hombre insignificante es el cuello de botella de otro hombre
James

1
-0x8(%rbp). oh mi ... esa sintaxis de AT&T.
Abyx

" Tres instrucciones adicionales " no, sólo dos: cargar el VPTR y cargar la función de puntero
curiousguy

@curiousguy es, de hecho, tres instrucciones adicionales. Ha olvidado que un método virtual siempre se llama en un puntero , por lo que primero debe cargar el puntero en un registro. En resumen, el primer paso es cargar la dirección que contiene la variable de puntero en el registro% rax, luego de acuerdo con la dirección en el registro, cargue el vtpr en esta dirección para registrar% rax, luego de acuerdo con esta dirección en el regístrese, cargue la dirección del método a llamar en% rax, luego llame a q *% rax !.
Gab 是 好人

18

¿Más lento que qué ?

Las funciones virtuales resuelven un problema que no puede resolverse mediante llamadas a funciones directas. En general, solo puede comparar dos programas que computan lo mismo. "Este rastreador de rayos es más rápido que ese compilador" no tiene sentido, y este principio se generaliza incluso a cosas pequeñas como funciones individuales o construcciones de lenguaje de programación.

Si no utiliza una función virtual para cambiar dinámicamente a un fragmento de código basado en un dato, como el tipo de un objeto, tendrá que usar otra cosa, como una switchdeclaración para lograr lo mismo. Ese algo más tiene sus propios gastos generales, más implicaciones en la organización del programa que influyen en su capacidad de mantenimiento y rendimiento global.

Tenga en cuenta que en C ++, las llamadas a funciones virtuales no siempre son dinámicas. Cuando las llamadas se realizan en un objeto cuyo tipo exacto se conoce (porque el objeto no es un puntero o referencia, o porque su tipo puede inferirse estáticamente), las llamadas son solo llamadas de funciones miembro regulares. Eso no solo significa que no hay gastos generales de envío, sino también que estas llamadas pueden alinearse de la misma manera que las llamadas normales.

En otras palabras, su compilador de C ++ puede funcionar cuando las funciones virtuales no requieren un despacho virtual, por lo que generalmente no hay razón para preocuparse por su rendimiento en relación con las funciones no virtuales.

Nuevo: Además, no debemos olvidar las bibliotecas compartidas. Si está utilizando una clase que está en una biblioteca compartida, la llamada a una función miembro ordinaria no será simplemente una secuencia de instrucciones agradable como callq 0x4007aa. Tiene que pasar por algunos aros, como indirectamente a través de una "tabla de enlaces de programa" o alguna estructura similar. Por lo tanto, la indirección de la biblioteca compartida podría nivelar (si no completamente) la diferencia de costo entre una llamada virtual (verdaderamente indirecta) y una llamada directa. Por lo tanto, el razonamiento sobre las compensaciones de funciones virtuales debe tener en cuenta cómo se construye el programa: si la clase del objeto de destino está vinculada monolíticamente al programa que está realizando la llamada.


44
"¿Más lento que qué?" - Si crea un método virtual que no tiene que ser así, tiene un material de comparación bastante bueno.
tdammers

2
Gracias por señalar que las llamadas a funciones virtuales no siempre son dinámicas. Cualquier otra respuesta aquí hace que parezca que declarar una función virtual significa un golpe de rendimiento automático, independientemente de las circunstancias.
Syndog

12

porque una llamada virtual es equivalente a

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

donde con una función no virtual el compilador puede doblar constantemente la primera línea, esta es una desreferencia, una adición y una llamada dinámica transformada en solo una llamada estática

esto también le permite alinear la función (con todas las debidas consecuencias de optimización)

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.