¿Cuál es el costo de rendimiento de tener un método virtual en una clase de C ++?

Tener al menos un método virtual en una clase de C ++ (o cualquiera de sus clases principales) significa que la clase tendrá una tabla virtual, y cada instancia tendrá un puntero virtual.

Entonces, el costo de la memoria es bastante claro. El más importante es el costo de memoria en las instancias (especialmente si las instancias son pequeñas, por ejemplo, si solo se pretende que contengan un entero: en este caso, tener un puntero virtual en cada instancia podría duplicar el tamaño de las instancias. el espacio de memoria utilizado por las tablas virtuales, supongo que generalmente es insignificante en comparación con el espacio utilizado por el código de método real.

Esto me lleva a mi pregunta: ¿hay un costo de rendimiento mensurable (es decir, impacto de velocidad) para hacer que un método sea virtual? Habrá una búsqueda en la tabla virtual en tiempo de ejecución, en cada llamada a un método, por lo que si hay llamadas frecuentes a este método, y si este método es muy corto, entonces puede haber un golpe de rendimiento mensurable. Supongo que depende de la plataforma, pero ¿alguien ha ejecutado algunos puntos de referencia?

La razón por la que estoy preguntando es que me encontré con un error que se debió a que un progtwigdor olvidó definir un método virtual. Esta no es la primera vez que veo este tipo de error. Y pensé: ¿por qué agregamos la palabra clave virtual cuando es necesario en lugar de eliminar la palabra clave virtual cuando estamos absolutamente seguros de que no es necesaria? Si el costo de rendimiento es bajo, creo que simplemente recomendaré lo siguiente en mi equipo: simplemente haga que todos los métodos sean virtuales por defecto, incluido el destructor, en todas las clases, y solo elimínelos cuando lo necesite. ¿Te parece loco?

Ejecuté algunos tiempos en un procesador PowerPC de 3 ghz en orden. En esa architecture, una llamada a función virtual cuesta 7 nanosegundos más que una llamada a función directa (no virtual).

Por lo tanto, no vale la pena preocuparse por el costo a menos que la función sea algo así como un acceso trivial de Get () / Set (), en el que cualquier cosa que no sea en línea es un desperdicio. Una sobrecarga de 7ns en una función que se refiere a 0.5ns es grave; una sobrecarga de 7ns en una función que tarda 500 ms en ejecutarse no tiene sentido.

El gran costo de las funciones virtuales no es realmente la búsqueda de un puntero de función en el vtable (que generalmente es solo un ciclo), sino que el salto indirecto generalmente no se puede predecir en una twig. Esto puede causar una burbuja grande en la tubería ya que el procesador no puede obtener ninguna instrucción hasta que el salto indirecto (la llamada a través del puntero a la función) se haya retirado y se haya calculado un nuevo puntero de instrucción. Entonces, el costo de una llamada de función virtual es mucho más grande de lo que parece al mirar el ensamblado … pero aún solo 7 nanosegundos.

Editar: Andrew, no estoy seguro, y otros también plantean el muy buen punto de que una llamada de función virtual puede causar una falta de caché de instrucciones: si saltas a una dirección de código que no está en caché, entonces todo el progtwig se detiene mientras el las instrucciones se obtienen de la memoria principal. Este es siempre un puesto importante: en Xenon, alrededor de 650 ciclos (según mis pruebas).

Sin embargo, este no es un problema específico de las funciones virtuales porque incluso una llamada de función directa causará una falla si se salta a las instrucciones que no están en la memoria caché. Lo que importa es si la función se ejecutó antes (lo que hace que sea más probable que esté en caché), y si su architecture puede predecir twigs estáticas (no virtuales) y obtener esas instrucciones en la memoria caché con anticipación. Mi PPC no, pero tal vez el hardware más reciente de Intel sí.

Mis tiempos controlan la influencia de icache falla en la ejecución (deliberadamente, ya que estaba tratando de examinar la tubería de la CPU de forma aislada), por lo que se descuenta ese costo.

Definitivamente hay una carga mensurable al llamar a una función virtual: la llamada debe usar el vtable para resolver la dirección de la función para ese tipo de objeto. Las instrucciones adicionales son la menor de tus preocupaciones. Los vtables no solo evitan muchas optimizaciones potenciales del comstackdor (ya que el tipo es polimórfico, el comstackdor) también pueden dañar tu I-Cache.

Por supuesto, si estas sanciones son importantes o no depende de su aplicación, con qué frecuencia se ejecutan esas rutas de código y sus patrones de herencia.

En mi opinión, sin embargo, tener todo de forma virtual por defecto es una solución general a un problema que podrías resolver de otras maneras.

Tal vez podrías ver cómo se diseñan / documentan / escriben las clases. En general, el encabezado de una clase debe dejar muy claro qué funciones pueden ser reemplazadas por las clases derivadas y cómo se llaman. Hacer que los progtwigdores escriban esta documentación es útil para garantizar que estén marcados correctamente como virtuales.

También diría que declarar cada función como virtual podría generar más errores que simplemente olvidar marcar algo como virtual. Si todas las funciones son virtuales, todo puede ser reemplazado por clases base, públicas, protegidas, privadas, todo se convierte en un juego limpio. Por accidente o por intención, las subclases pueden cambiar el comportamiento de las funciones que luego causan problemas cuando se usan en la implementación base.

Depende. 🙂 (¿Había esperado algo más?)

Una vez que una clase obtiene una función virtual, ya no puede ser un tipo de datos POD, (puede que tampoco haya sido uno antes, en cuyo caso esto no hará la diferencia) y eso hace imposible toda una gama de optimizaciones.

std :: copy () en tipos de POD simples puede recurrir a una rutina memcpy simple, pero los tipos que no son POD deben manejarse con más cuidado.

La construcción se vuelve mucho más lenta porque el vtable debe inicializarse. En el peor de los casos, la diferencia de rendimiento entre los tipos de datos POD y no POD puede ser significativa.

En el peor de los casos, es posible que veas una ejecución 5 veces más lenta (ese número proviene de un proyecto universitario que realicé recientemente para volver a implementar algunas clases de biblioteca estándar.) Nuestro contenedor tardó aproximadamente 5 veces más en construirse tan pronto como el tipo de datos almacenados obtuviera vtable)

Por supuesto, en la mayoría de los casos, es poco probable que vea una diferencia de rendimiento mensurable, esto es simplemente para señalar que en algunos casos fronterizos, puede ser costoso.

Sin embargo, el rendimiento no debe ser su principal consideración aquí. Hacer que todo sea virtual no es una solución perfecta por otras razones.

Permitir que todo se anule en clases derivadas hace que sea mucho más difícil mantener invariantes de clase. ¿Cómo garantiza una clase que se mantenga en un estado constante cuando cualquiera de sus métodos podría redefinirse en cualquier momento?

Hacer que todo sea virtual puede eliminar algunos posibles errores, pero también introduce otros nuevos.

Si necesita la funcionalidad del despacho virtual, debe pagar el precio. La ventaja de C ++ es que puede utilizar una implementación muy eficiente del despacho virtual proporcionado por el comstackdor, en lugar de una versión posiblemente ineficiente que usted mismo implemente.

Sin embargo, al agobiarse con los gastos generales, si no lo necesita, es posible que vaya demasiado lejos. Y la mayoría de las clases no están diseñadas para ser heredadas: para crear una buena clase base se requiere algo más que virtualizar sus funciones.

El despacho virtual es un orden de magnitud más lento que algunas alternativas, no tanto por la indirección como por la prevención de la creación de línea. A continuación, lo ilustro contrastando el despacho virtual con una implementación que incorpora un “número de tipo (identificación)” en los objetos y el uso de una instrucción de conmutación para seleccionar el código específico del tipo. Esto evita por completo la sobrecarga de llamada a la función, simplemente haciendo un salto local. Existe un costo potencial para el mantenimiento, las dependencias de recomstackción, etc. a través de la localización forzada (en el conmutador) de la funcionalidad específica del tipo.


IMPLEMENTACIÓN

 #include  #include  // virtual dispatch model... struct Base { virtual int f() const { return 1; } }; struct Derived : Base { virtual int f() const { return 2; } }; // alternative: member variable encodes runtime type... struct Type { Type(int type) : type_(type) { } int type_; }; struct A : Type { A() : Type(1) { } int f() const { return 1; } }; struct B : Type { B() : Type(2) { } int f() const { return 2; } }; struct Timer { Timer() { clock_gettime(CLOCK_MONOTONIC, &from); } struct timespec from; double elapsed() const { struct timespec to; clock_gettime(CLOCK_MONOTONIC, &to); return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec); } }; int main(int argc) { for (int j = 0; j < 3; ++j) { typedef std::vector V; V v; for (int i = 0; i < 1000; ++i) v.push_back(i % 2 ? new Base : (Base*)new Derived); int total = 0; Timer tv; for (int i = 0; i < 100000; ++i) for (V::const_iterator i = v.begin(); i != v.end(); ++i) total += (*i)->f(); double tve = tv.elapsed(); std::cout < < "virtual dispatch: " << total << ' ' << tve << '\n'; // ---------------------------- typedef std::vector W; W w; for (int i = 0; i < 1000; ++i) w.push_back(i % 2 ? (Type*)new A : (Type*)new B); total = 0; Timer tw; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) { if ((*i)->type_ == 1) total += ((A*)(*i))->f(); else total += ((B*)(*i))->f(); } double twe = tw.elapsed(); std::cout < < "switched: " << total << ' ' << twe << '\n'; // ---------------------------- total = 0; Timer tw2; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) total += (*i)->type_; double tw2e = tw2.elapsed(); std::cout < < "overheads: " << total << ' ' << tw2e << '\n'; } } 

RESULTADOS DE RENDIMIENTO

En mi sistema Linux:

 ~/dev g++ -O2 -o vdt vdt.cc -lrt ~/dev ./vdt virtual dispatch: 150000000 1.28025 switched: 150000000 0.344314 overhead: 150000000 0.229018 virtual dispatch: 150000000 1.285 switched: 150000000 0.345367 overhead: 150000000 0.231051 virtual dispatch: 150000000 1.28969 switched: 150000000 0.345876 overhead: 150000000 0.230726 

Esto sugiere que un enfoque en línea de tipo de número conmutado es aproximadamente (1.28 - 0.23) / (0.344 - 0.23) = 9.2 veces más rápido. Por supuesto, eso es específico del sistema exacto probado / comstackdor de indicadores y versión, etc., pero generalmente indicativo.


COMENTARIOS RE VIRTUAL DESPACHO

Sin embargo, debe decirse que los gastos generales de llamadas a funciones virtuales son algo que raramente es significativo, y luego solo para las llamadas funciones triviales (como getters y setters). Incluso entonces, es posible que pueda proporcionar una sola función para obtener y configurar un montón de cosas a la vez, lo que minimiza el costo. A la gente le preocupa mucho el envío virtual, así que haz el perfil antes de encontrar alternativas incómodas. El problema principal con ellos es que realizan una llamada de función fuera de línea, aunque también deslocalizan el código ejecutado que cambia los patrones de utilización de la memoria caché (para mejor o (más a menudo) peor).

El costo adicional es virtualmente nada en la mayoría de los escenarios. (perdón por el juego de palabras). ejac ya ha publicado medidas relativas sensatas.

Lo más importante a lo que renunciar es posibles optimizaciones debido a la creación de líneas. Pueden ser especialmente buenos si la función se llama con parámetros constantes. Esto rara vez hace una diferencia real, pero en algunos casos, esto puede ser enorme.


En cuanto a optimizaciones:
Es importante conocer y considerar el costo relativo de los constructos de su idioma. La notación de Big O es la mitad de la historia, ¿cómo se escala su aplicación ? La otra mitad es el factor constante en frente de ella.

Como regla general, no saldría del camino para evitar las funciones virtuales, a menos que haya indicaciones claras y específicas de que es un cuello de botella. Un diseño limpio siempre es lo primero, pero es solo una parte interesada que no debería dañar indebidamente a los demás.


Ejemplo de error: un destructor virtual vacío en una matriz de un millón de elementos pequeños puede atravesar al menos 4 MB de datos, agitando su caché. Si ese destructor se puede alinear, los datos no se tocarán.

Al escribir el código de la biblioteca, tales consideraciones están lejos de ser prematuras. Nunca se sabe cuántos bucles se colocarán alrededor de su función.

Si bien todos los demás tienen razón sobre el rendimiento de los métodos virtuales, creo que el verdadero problema es si el equipo conoce la definición de la palabra clave virtual en C ++.

Considere este código, ¿cuál es el resultado?

 #include  class A { public: void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

Nada sorprendente aquí:

 A::Foo() B::Foo() A::Foo() 

Como nada es virtual. Si la palabra clave virtual se agrega al frente de Foo en las clases A y B, obtenemos esto para el resultado:

 A::Foo() B::Foo() B::Foo() 

Más o menos lo que todos esperan.

Ahora, mencionó que hay errores porque alguien olvidó agregar una palabra clave virtual. Así que considere este código (donde la palabra clave virtual se agrega a A, pero no a la clase B). ¿Cuál es el resultado entonces?

 #include  class A { public: virtual void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

Respuesta: ¿Lo mismo que si la palabra clave virtual se agrega a B? La razón es que la firma para B :: Foo coincide exactamente con A :: Foo () y porque A’s Foo es virtual, también lo es B’s.

Ahora considere el caso donde B’s Foo es virtual y A’s no lo es. ¿Cuál es el resultado entonces? En este caso, la salida es

 A::Foo() B::Foo() A::Foo() 

La palabra clave virtual funciona hacia abajo en la jerarquía, no hacia arriba. Nunca hace que los métodos de la clase base sean virtuales. La primera vez que se encuentra un método virtual en la jerarquía es cuando comienza el polymorphism. No hay forma de que las clases posteriores hagan que las clases anteriores tengan métodos virtuales.

No olvide que los métodos virtuales significan que esta clase le está dando a las clases futuras la capacidad de anular / cambiar algunos de sus comportamientos.

Por lo tanto, si tiene una regla para eliminar la palabra clave virtual, es posible que no tenga el efecto deseado.

La palabra clave virtual en C ++ es un concepto poderoso. Debes asegurarte de que cada miembro del equipo conozca realmente este concepto para que se pueda usar según lo diseñado.

Dependiendo de su plataforma, la sobrecarga de una llamada virtual puede ser muy indeseable. Al declarar cada función virtual, esencialmente las llamas a todas a través de un puntero de función. Por lo menos, esto es una desreferencia adicional, pero en algunas plataformas de PPC usará instrucciones microcodificadas o lentas para lograr esto.

Recomendaría en contra de su sugerencia por este motivo, pero si lo ayuda a prevenir errores, entonces puede valer la pena compensarlo. Sin embargo, no puedo evitar pensar que debe haber algún terreno intermedio que valga la pena encontrar.

Se requerirá solo un par de instrucciones adicionales para llamar al método virtual.

Pero no creo que te preocupes, la diversión (int a, int b) tiene un par de instrucciones extra ‘push’ en comparación con fun (). Así que no te preocupes por los virtuales también, hasta que estés en una situación especial y veas que realmente te lleva a problemas.

PD: si tiene un método virtual, asegúrese de tener un destructor virtual. De esta forma evitarás posibles problemas


En respuesta a los comentarios ‘xtofl’ y ‘Tom’. Hice pruebas pequeñas con 3 funciones:

  1. Virtual
  2. Normal
  3. Normal con 3 parámetros int

Mi prueba fue una iteración simple:

 for(int it = 0; it < 100000000; it ++) { test.Method(); } 

Y aquí los resultados:

  1. 3,913 sec
  2. 3,873 sec
  3. 3,970 sec

Fue comstackdo por VC ++ en modo de depuración. Solo hice 5 pruebas por método y calculé el valor promedio (por lo que los resultados pueden ser bastante imprecisos) ... De todos modos, los valores son casi iguales asumiendo 100 millones de llamadas. Y el método con 3 push / pop extra fue más lento.

El punto principal es que si no te gusta la analogía con el push / pop, piensa en if / else extra en tu código? ¿Piensa en la interconexión de CPU cuando agrega más si / else 😉 Además, nunca se sabe en qué CPU se ejecutará el código ... El comstackdor habitual puede generar código más óptimo para una CPU y menos óptimo para otra ( Intel Comstackdor C ++ )