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


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

Así que el costo de 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 están destinadas a contener un entero: en este caso, tener un puntero virtual en cada instancia podría duplicar el tamaño de las instancias. En cuanto a la 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 del método real.

Esto me lleva a mi pregunta: ¿hay un costo de rendimiento mensurable (es decir, impacto en la velocidad) para hacer virtual un método? Habrá una búsqueda en la tabla virtual en tiempo de ejecución, en cada llamada de método, por lo que si hay llamadas muy frecuentes a este método, y si este método es muy corto, entonces podría haber un éxito de rendimiento medible? 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 programador 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 necesaria 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 recomiendo lo siguiente en mi equipo: simplemente haga que cada método sea virtual por defecto, incluido el destructor, en cada clase, y solo elimínelo cuando lo necesite. ¿Te parece una locura?

Author: Tshepang, 2009-03-20

9 answers

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

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

El gran costo de las funciones virtuales no es realmente la búsqueda de un puntero de función en la vtable (que generalmente es solo un ciclo), sino que el salto indirecto generalmente no se puede predecir por ramas. Esto puede causar una gran burbuja de canalización ya que el procesador no puede obtener ninguna instrucción hasta que el salto indirecto (la llamada a través del puntero de función) se haya retirado y se haya calculado un nuevo puntero de instrucción. Por lo tanto, el costo de una llamada a una función virtual es mucho mayor de lo que podría parecer de mirar la asamblea... pero todavía sólo 7 nanosegundos.

Edit: Andrew, Not Sure, y otros también plantean el muy buen punto de que una llamada a una función virtual puede causar un error de caché de instrucciones: si salta a una dirección de código que no está en caché, entonces todo el programa se detiene mientras las instrucciones se obtienen de la memoria principal. Esto es siempre un estancamiento significativo: en Xenón, alrededor de 650 ciclos (según mis pruebas).

Sin embargo, esto no es un problema específico para funciones virtuales porque incluso una llamada directa a una función causará un error si salta a instrucciones que no están en caché. Lo que importa es si la función se ha ejecutado recientemente (lo que hace que sea más probable que esté en caché), y si su arquitectura puede predecir ramas estáticas (no virtuales) y recuperar esas instrucciones en caché con anticipación. Mi PPC no lo hace, pero tal vez el hardware más reciente de Intel lo hace.

Mi control de tiempos para la influencia de icache falla ejecución (deliberadamente, ya que estaba tratando de examinar la tubería de CPU de forma aislada), por lo que descuentan ese costo.

 86
Author: Crashworks,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-05-18 18:33:29

Definitivamente hay una sobrecarga medible cuando se llama a una función virtual - la llamada debe usar la 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 compilador (ya que el tipo es polimórfico del compilador), también pueden destruir su I-Cache.

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

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

Quizás podría ver cómo se diseñan/documentan/escriben las clases. En general, la cabecera de una clase debe dejar muy claro qué funciones pueden ser anuladas por clases derivadas y cómo se llaman. Hacer que los programadores escriban esta documentación es útil para garantizar que estén marcados correctamente como virtual.

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

 17
Author: Andrew Grant,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-21 04:46:17

Depende. :) (¿Esperabas algo más?)

Una vez que una clase obtiene una función virtual, ya no puede ser un tipo de datos POD, (puede que no lo haya sido antes, en cuyo caso esto no hará ninguna diferencia) y eso hace que todo un rango de optimizaciones sea imposible.

Std::copy() en los 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 la vtable tiene que ser inicialización. En el peor de los casos, la diferencia en el rendimiento entre los tipos de datos POD y no POD puede ser significativa.

En el peor de los casos, puede ver una ejecución 5 veces más lenta (ese número se toma de un proyecto universitario que hice recientemente para reimplementar algunas clases de bibliotecas estándar. Nuestro contenedor tardó aproximadamente 5 veces más en construirse tan pronto como el tipo de datos que almacenó obtuvo una vtable)

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

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

Permitir que todo sea anulado en clases derivadas hace mucho más difícil mantener invariantes de clase. ¿Cómo garantiza una clase que permanece en un estado consistente cuando cualquiera de sus métodos podría ser redefinido en cualquier momento?

Haciendo todo virtual puede eliminar algunos errores potenciales, pero también introduce otros nuevos.

 8
Author: jalf,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-21 14:09:53

Si necesita la funcionalidad de envío virtual, debe pagar el precio. La ventaja de C++ es que puede usar una implementación muy eficiente de virtual dispatch proporcionada por el compilador, en lugar de una versión posiblemente ineficiente que implemente usted mismo.

Sin embargo, cargarse con la sobrecarga si no lo necesita es posiblemente ir un poco demasiado lejos. Y la mayoría de las clases no están diseñadas para ser heredadas de-para crear una buena clase base requiere más que hacer su funciones virtuales.

 7
Author: ,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-20 19:34:22

El envío virtual es un orden de magnitud más lento que algunas alternativas, no debido tanto a la indirección como a la prevención de la inserción. A continuación, ilustro esto contrastando virtual dispatch con una implementación que incrusta un "número (de identificación) de tipo" en los objetos y utiliza una instrucción switch para seleccionar el código específico del tipo. Esto evita la sobrecarga de llamadas a funciones por completo, solo haciendo un salto local. Hay un costo potencial para la mantenibilidad, las dependencias de recompilación, etc mediante la localización forzada (en el conmutador) de la funcionalidad específica del tipo.


APLICACIÓN

#include <iostream>
#include <vector>

// 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<Base*> 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<Type*> 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 de tipo-número-conmutado en línea es acerca de (1.28 - 0.23) / (0.344 - 0.23) = 9.2 veces más rápido. Por supuesto, eso es específico para el sistema exacto probado / banderas y versión del compilador, etc., pero generalmente indicativo.


OBSERVACIONES RE VIRTUAL DISPATCH

Sin embargo, debe decirse que los gastos generales de llamada a funciones virtuales son algo que rara vez es significativo, y solo para funciones a menudo llamadas triviales (como getters y setters). Incluso entonces, es posible que pueda proporcionar una sola función para obtener y establecer un montón de cosas a la vez, minimizando el costo. La gente se preocupa demasiado por el envío virtual, así que haga el perfil antes de encontrar alternativas incómodas. El principal problema con ellos es que realizan una llamada a una función fuera de línea, aunque también deslocalizan el código ejecutado, lo que cambia los patrones de utilización de la caché (para bien o (más a menudo) para mal).

 5
Author: Tony Delroy,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-01-26 08:05:13

El costo adicional es prácticamente 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 que renuncias son las posibles optimizaciones debido a la inserción. 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.


Con respecto a las optimizaciones:
Es importante conocer y considerar el costo relativo de las construcciones de su idioma. La notación Big O es solo la mitad de la historia - cómo escala su aplicación. La otra mitad es el factor constante frente a ella.

Como regla general, no me desviaría de mi 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 un stakeholder el que no debería dañar indebidamente a los demás.


Ejemplo artificial: Un destructor virtual vacío en un array de un millón de pequeños elementos puede arar a través de al menos 4MB de datos, golpeando su caché. Si ese destructor puede ser inlineado lejos, los datos no serán tocados.

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

 3
Author: peterchen,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-20 20:19:57

Mientras que todos los demás tienen razón sobre el rendimiento de los métodos virtuales y demás, 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 la salida?

#include <stdio.h>

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()

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

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

Más o menos lo que todo el mundo esperar.

Ahora, usted 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 la salida entonces?

#include <stdio.h>

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 añade a B? La razón es que la firma para B::Foo coincide exactamente como A::Foo() y porque el Foo de A es virtual, también lo es el de B.

Ahora consideremos el caso donde el Foo de B es virtual y el de A no lo es. Cuál es la salida 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 polimorfismo. No hay una manera para que las clases posteriores hagan que las clases anteriores tengan métodos virtuales.

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

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. Debe asegurarse de que cada miembro del equipo realmente conozca este concepto para que pueda usarse según lo diseñado.

 2
Author: Tommy Hui,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-22 17:08:27

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 extra, pero en algunas plataformas PPC usará instrucciones microcodificadas o lentas para lograr esto.

Recomendaría contra su sugerencia por esta razón, pero si le ayuda a prevenir errores, entonces puede valer la pena el intercambio. No puedo evitar creo que debe haber un término medio que vale la pena encontrar, sin embargo.

 1
Author: Dan Olson,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-20 19:38:21

Requerirá solo un par de instrucciones adicionales de asm para llamar al método virtual.

Pero no creo que te preocupe que fun(int a, int b) tenga un par de instrucciones 'push' adicionales en comparación con fun(). Así que no se preocupe por los virtuals también, hasta que esté en una situación especial y vea que realmente conduce a problemas.

P.d. Si tiene un método virtual, asegúrese de tener un destructor virtual. De esta manera evitarás posibles problemas


En respuesta a 'xtofl' y 'Tom' comentario. 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 seg
  3. 3.970 seg

Fue compilado por VC++ en modo de depuración. Hice solo 5 pruebas por método y calculé el valor medio (por lo que los resultados pueden ser bastante inexactos)... De cualquier manera, los valores son casi iguales asumiendo 100 millones de llamadas. Y el método con 3 push/pop extra era más lento.

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

 -1
Author: alex2k8,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2009-03-22 23:38:15