En general, ¿vale la pena usar funciones virtuales para evitar ramificaciones?

21

Parece que hay equivalentes aproximados de instrucciones para igualar el costo de una rama que pierde funciones virtuales que tienen una compensación similar:

  • instrucción vs. fallo de caché de datos
  • barrera de optimización

Si miras algo como:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

Podría tener una matriz de funciones miembro, o si muchas funciones dependen de la misma categorización, o si existe una categorización más compleja, use funciones virtuales:

p->do()

Pero, en general, qué tan caras son las funciones virtuales frente a la ramificación. Es difícil probar en suficientes plataformas para generalizar, por lo que me preguntaba si alguien tenía una regla general aproximada (encantador si ifel punto de interrupción fuera tan simple como 4 s)

En general, las funciones virtuales son más claras y me inclinaría hacia ellas. Pero, tengo varias secciones muy críticas donde puedo cambiar el código de funciones virtuales a ramas. Preferiría tener pensamientos sobre esto antes de emprender esto. (no es un cambio trivial o fácil de probar en múltiples plataformas)

Glenn Teitelbaum
fuente
12
Bueno, ¿cuáles son tus requisitos de rendimiento? ¿Tiene números difíciles que tiene que acertar o está participando en una optimización prematura? Tanto la ramificación como los métodos virtuales son extremadamente baratos en el gran esquema de las cosas (por ejemplo, en comparación con algoritmos defectuosos, E / S o asignación de montón).
amon
44
Haga lo que sea más legible / flexible / poco probable que se interponga en el camino de los cambios futuros, y una vez que lo tenga funcionando, entonces haga un perfil y vea si esto realmente importa. Por lo general no lo hace.
Ixrec
1
Pregunta: "Pero, en general, qué tan caras son las funciones virtuales ..." Respuesta: rama indirecta (wikipedia)
rwong
1
Recuerde que la mayoría de las respuestas se basan en contar el número de instrucciones. Como optimizador de código de bajo nivel, no confío en la cantidad de instrucciones; debe probarlos en una arquitectura de CPU particular, físicamente, en condiciones experimentales. Las respuestas válidas para esta pregunta deben ser empíricas y experimentales, no teóricas.
rwong
3
El problema con esta pregunta es que presupone que es lo suficientemente grande como para preocuparse. En el software real, los problemas de rendimiento vienen en grandes porciones, como porciones de pizza de varios tamaños. Por ejemplo mira aquí . No asumas que sabes cuál es el mayor problema: deja que el programa te lo diga. Arregla eso, y luego deja que te diga cuál es el próximo. Haga esto media docena de veces, y es posible que tenga que preocuparse por las llamadas a funciones virtuales. Nunca lo han hecho, en mi experiencia.
Mike Dunlavey, el

Respuestas:

21

Quería saltar aquí entre estas respuestas ya excelentes y admitir que he tomado el enfoque feo de trabajar realmente hacia atrás al antipatrón de cambiar el código polimórfico switcheso if/elseramas con ganancias medidas. Pero no hice esto al por mayor, solo para los caminos más críticos. No tiene que ser tan blanco y negro.

Como descargo de responsabilidad, trabajo en áreas como el trazado de rayos donde la corrección no es tan difícil de lograr (y a menudo es borrosa y aproximada de todos modos), mientras que la velocidad es a menudo una de las cualidades más competitivas buscadas. Una reducción en los tiempos de renderizado es a menudo una de las solicitudes más comunes de los usuarios, con nosotros constantemente rascándonos la cabeza y descubriendo cómo lograrlo para los caminos medidos más críticos.

Refactorización polimórfica de condicionales

Primero, vale la pena entender por qué el polimorfismo puede ser preferible desde un aspecto de mantenimiento que la ramificación condicional ( switcho un conjunto de if/elsedeclaraciones). El principal beneficio aquí es la extensibilidad .

Con el código polimórfico, podemos introducir un nuevo subtipo en nuestra base de código, agregar instancias de él a alguna estructura de datos polimórficos y hacer que todo el código polimórfico existente siga funcionando automáticamente sin más modificaciones. Si tiene un montón de código disperso en una base de código grande que se asemeja a la forma de, "Si este tipo es 'foo', hágalo" , puede encontrarse con una carga horrible de actualizar 50 secciones dispares de código para introducir un nuevo tipo de cosas, y todavía terminan perdiendo algunas

Los beneficios de mantenimiento del polimorfismo naturalmente disminuyen aquí si solo tiene un par o incluso una sección de su base de código que necesita hacer tales verificaciones de tipo.

Barrera de optimización

Sugeriría no mirar esto desde el punto de vista de ramificar y canalizar tanto, y mirarlo más desde la mentalidad de diseño del compilador de las barreras de optimización. Hay formas de mejorar la predicción de rama que se aplica a ambos casos, como ordenar los datos según el subtipo (si encaja en una secuencia).

Lo que difiere más entre estas dos estrategias es la cantidad de información que el optimizador tiene de antemano. Una llamada de función que se conoce proporciona mucha más información, una llamada de función indirecta que llama a una función desconocida en tiempo de compilación conduce a una barrera de optimización.

Cuando se conoce la función que se llama, los compiladores pueden borrar la estructura y reducirla a fragmentos, alineando llamadas, eliminando posibles sobrecargas de alias, haciendo un mejor trabajo en la asignación de instrucción / registro, posiblemente incluso reorganizando bucles y otras formas de ramas, generando LUT en miniatura codificadas cuando sea apropiado (algo que GCC 5.3 recientemente me sorprendió con una switchdeclaración al usar una LUT codificada de datos para los resultados en lugar de una tabla de salto).

Algunos de esos beneficios se pierden cuando comenzamos a introducir incógnitas en tiempo de compilación en la mezcla, como en el caso de una llamada de función indirecta, y ahí es donde la ramificación condicional puede ofrecer una ventaja.

Optimización de memoria

Tomemos un ejemplo de un videojuego que consiste en procesar una secuencia de criaturas repetidamente en un ciclo cerrado. En tal caso, podríamos tener algún contenedor polimórfico como este:

vector<Creature*> creatures;

Nota: por simplicidad, evité unique_ptraquí.

... donde Creaturees un tipo de base polimórfica. En este caso, una de las dificultades con los contenedores polimórficos es que a menudo desean asignar memoria para cada subtipo por separado / individualmente (por ejemplo: usar el lanzamiento predeterminado operator newpara cada criatura individual).

Eso a menudo hará que la primera prioridad para la optimización (en caso de que la necesitemos) se base en la memoria en lugar de ramificarse. Una estrategia aquí es utilizar un asignador fijo para cada subtipo, fomentando una representación contigua al asignar en grandes fragmentos y agrupando la memoria para cada subtipo que se asigna. Con tal estrategia, definitivamente puede ayudar clasificar este creaturescontenedor por subtipo (así como por dirección), ya que eso no solo mejora la predicción de ramas sino que también mejora la localidad de referencia (permitiendo el acceso a múltiples criaturas del mismo subtipo) desde una sola línea de caché antes del desalojo).

Desvirtualización parcial de estructuras de datos y bucles

Digamos que realizó todos estos movimientos y aún desea más velocidad. Vale la pena señalar que cada paso que aventuramos aquí está degradando la mantenibilidad, y ya estaremos en una etapa de molienda de metales con rendimientos de rendimiento decrecientes. Por lo tanto, debe haber una demanda de rendimiento bastante significativa si pisamos este territorio, donde estamos dispuestos a sacrificar la capacidad de mantenimiento aún más para obtener ganancias de rendimiento cada vez más pequeñas.

Sin embargo, el siguiente paso para intentar (y siempre con la voluntad de respaldar nuestros cambios si no ayuda en absoluto) podría ser la desvirtualización manual.

Consejo de control de versiones: a menos que sea mucho más experto en optimización que yo, puede valer la pena crear una nueva sucursal en este punto con la voluntad de deshacerse de ella si nuestros esfuerzos de optimización fallan, lo que muy bien puede suceder. Para mí, todo es prueba y error después de este tipo de puntos, incluso con un perfilador en la mano.

Sin embargo, no tenemos que aplicar esta mentalidad al por mayor. Continuando con nuestro ejemplo, digamos que este videojuego consiste principalmente en criaturas humanas, con mucho. En tal caso, podemos desvirtualizar solo criaturas humanas al izarlas y crear una estructura de datos separada solo para ellas.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

Esto implica que todas las áreas de nuestra base de código que necesitan procesar criaturas necesitan un bucle de casos especiales separado para las criaturas humanas. Sin embargo, eso elimina la sobrecarga dinámica de despacho (o quizás, más apropiadamente, la barrera de optimización) para los humanos, que son, con mucho, el tipo de criatura más común. Si estas áreas son numerosas y podemos pagarlo, podríamos hacer esto:

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

... si podemos permitirnos esto, los caminos menos críticos pueden permanecer como están y simplemente procesar todos los tipos de criaturas de forma abstracta. Las rutas críticas pueden procesarse humansen un bucle y other_creaturesen un segundo bucle.

Podemos extender esta estrategia según sea necesario y potencialmente exprimir algunas ganancias de esta manera, sin embargo, vale la pena señalar cuánto estamos degradando la mantenibilidad en el proceso. El uso de plantillas de funciones aquí puede ayudar a generar el código para humanos y criaturas sin duplicar la lógica manualmente.

Desvirtualización parcial de clases

Algo que hice hace años que fue realmente asqueroso, y ya ni siquiera estoy seguro de que sea beneficioso (esto fue en la era C ++ 03), fue la desvirtualización parcial de una clase. En ese caso, ya estábamos almacenando un ID de clase con cada instancia para otros fines (se accede a través de un descriptor de acceso en la clase base que no era virtual). Allí hicimos algo análogo a esto (mi memoria es un poco confusa):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... donde virtual_do_somethingse implementó para llamar a versiones no virtuales en una subclase. Es asqueroso, lo sé, hacer un downcast estático explícito para desvirtualizar una llamada de función. No tengo idea de cuán beneficioso es esto ahora, ya que no he probado este tipo de cosas en años. Con una exposición al diseño orientado a datos, descubrí que la estrategia anterior de dividir las estructuras de datos y los bucles de manera fría / caliente es mucho más útil, abriendo más puertas para estrategias de optimización (y mucho menos fea).

Venta al por mayor Desvirtualización

Debo admitir que nunca he llegado tan lejos aplicando una mentalidad de optimización, por lo que no tengo idea de los beneficios. He evitado las funciones indirectas en previsión en los casos en que sabía que solo habría un conjunto central de condicionales (por ejemplo: procesamiento de eventos con solo un evento de procesamiento de lugar central), pero nunca comencé con una mentalidad polimórfica y optimizado por completo Hasta aquí.

Teóricamente, los beneficios inmediatos aquí podrían ser una forma potencialmente más pequeña de identificar un tipo que un puntero virtual (por ejemplo, un solo byte si puede comprometerse con la idea de que hay 256 tipos únicos o menos) además de eliminar por completo estas barreras de optimización .

También podría ayudar en algunos casos escribir código más fácil de mantener (en comparación con los ejemplos optimizados de desvirtualización manual anteriores) si solo usa una switchdeclaración central sin tener que dividir sus estructuras de datos y bucles según el subtipo, o si hay un pedido -dependencia en estos casos donde las cosas tienen que ser procesadas en un orden preciso (incluso si eso hace que nos ramifiquemos por todos lados). Esto sería para casos en los que no tienes muchos lugares que necesiten hacer switch.

En general, no recomendaría esto incluso con una mentalidad muy crítica de rendimiento a menos que sea razonablemente fácil de mantener. "Fácil de mantener" tendería a depender de dos factores dominantes:

  • No tener una necesidad de extensibilidad real (por ejemplo, saber con seguridad que tiene exactamente 8 tipos de cosas para procesar, y nunca más).
  • No tener muchos lugares en su código que necesiten verificar estos tipos (ej .: un lugar central).

... sin embargo, recomiendo el escenario anterior en la mayoría de los casos e iterando hacia soluciones más eficientes mediante la desvirtualización parcial, según sea necesario. Le da mucho más espacio para respirar para equilibrar las necesidades de extensibilidad y mantenibilidad con el rendimiento.

Funciones virtuales frente a punteros de función

Para colmo, noté aquí que había una discusión sobre las funciones virtuales frente a los punteros de función. Es cierto que las funciones virtuales requieren un poco de trabajo extra para llamar, pero eso no significa que sean más lentas. Contra-intuitivamente, incluso puede hacerlos más rápidos.

Aquí es contra-intuitivo porque estamos acostumbrados a medir el costo en términos de instrucciones sin prestar atención a la dinámica de la jerarquía de memoria que tiende a tener un impacto mucho más significativo.

Si estamos comparando un classcon 20 funciones virtuales versus un structque almacena 20 punteros de función, y ambos se instancian varias veces, la sobrecarga de memoria de cada classinstancia en este caso 8 bytes para el puntero virtual en máquinas de 64 bits, mientras que la memoria la sobrecarga del structes de 160 bytes.

El costo práctico puede haber muchos más errores de caché obligatorios y no obligatorios con la tabla de punteros de función frente a la clase que usa funciones virtuales (y posiblemente errores de página en una escala de entrada lo suficientemente grande). Ese costo tiende a eclipsar el trabajo ligeramente adicional de indexar una tabla virtual.

También he tratado con bases de código C heredadas (anteriores a las que tengo) en las que convertirlas en structspunteros de funciones, e instanciarlas en numerosas ocasiones, en realidad proporcionó ganancias de rendimiento significativas (más del 100% de mejoras) al convertirlas en clases con funciones virtuales, y simplemente debido a la reducción masiva en el uso de la memoria, el aumento de la capacidad de almacenamiento en caché, etc.

Por otro lado, cuando las comparaciones se vuelven más acerca de manzanas con manzanas, también he encontrado que la mentalidad opuesta de traducir de una mentalidad de función virtual C ++ a una mentalidad de puntero de función de estilo C es útil en este tipo de escenarios:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

... donde la clase estaba almacenando una única función invalidable (o dos si contamos el destructor virtual). En esos casos, definitivamente puede ayudar en los caminos críticos para convertir eso en esto:

void (*func_ptr)(void* instance_data);

... idealmente detrás de una interfaz de tipo seguro para ocultar los lanzamientos peligrosos void*.

En aquellos casos en los que estamos tentados a usar una clase con una sola función virtual, puede ayudar rápidamente usar punteros de función en su lugar. Una gran razón no es necesariamente el costo reducido de llamar a un puntero de función. Es porque ya no enfrentamos la tentación de asignar cada funciónoide separada en las regiones dispersas del montón si los estamos agregando en una estructura persistente. Este tipo de enfoque puede hacer que sea más fácil evitar la sobrecarga de fragmentación de memoria asociada con el montón si los datos de la instancia son homogéneos, por ejemplo, y solo varía el comportamiento.

Así que definitivamente hay algunos casos en los que el uso de punteros de función puede ayudar, pero a menudo lo he encontrado al revés si comparamos un montón de tablas de punteros de función con una única tabla virtual que solo requiere que se almacene un puntero por instancia de clase . Esa vtable a menudo estará sentada en una o más líneas de caché L1 también en bucles estrechos.

Conclusión

De todos modos, ese es mi pequeño giro sobre este tema. Recomiendo aventurarse en estas áreas con precaución. Confíe en las medidas, no en el instinto, y dada la forma en que estas optimizaciones a menudo degradan la capacidad de mantenimiento, solo llegan tan lejos como puede permitirse (y una ruta inteligente sería errar por el lado de la capacidad de mantenimiento).

marstato
fuente
La función virtual son punteros de función, recién implementados en lo viable de esa clase. Cuando se llama a una función virtual, primero se busca en el hijo y en la cadena de herencia. Es por eso que la herencia profunda es muy costosa y generalmente se evita en c ++.
Robert Baron
@RobertBaron: nunca he visto que se implementen funciones virtuales como dijiste (= con una búsqueda en cadena a través de la jerarquía de clases). En general, los compiladores solo generan una vtable "aplanada" para cada tipo concreto con todos los punteros de función correctos, y en tiempo de ejecución la llamada se resuelve con una sola búsqueda de tabla directa; no se paga penalidad por jerarquías de herencia profundas.
Matteo Italia
Matteo, esta fue la explicación que un líder técnico me dio hace muchos años. Por supuesto, era para c ++, por lo que puede haber tenido en cuenta las implicaciones de la herencia múltiple. Gracias por aclarar mi comprensión de cómo se optimizan las vtables.
Robert Baron
Gracias por la buena respuesta (+1). Me pregunto cuánto de esto aplica de manera idéntica para std :: visit en lugar de funciones virtuales.
DaveFar
13

Observaciones:

  • En muchos casos, las funciones virtuales son más rápidas porque la búsqueda de vtable es una O(1)operación mientras que la else if()escalera es una O(n)operación. Sin embargo, esto solo es cierto si la distribución de casos es plana.

  • Para un solo if() ... else, el condicional es más rápido porque guarda la sobrecarga de la llamada de función.

  • Entonces, cuando tiene una distribución plana de casos, debe existir un punto de equilibrio. La única pregunta es dónde está ubicado.

  • Si utiliza una llamada en switch()lugar de else if()llamadas de función virtuales o de escalera, su compilador puede producir un código aún mejor: puede hacer una bifurcación a una ubicación que se busca desde una tabla, pero que no es una llamada a función. Es decir, tiene todas las propiedades de la llamada a la función virtual sin todos los gastos generales de la llamada a la función.

  • Si uno es mucho más frecuente que el resto, comenzar if() ... elsecon un caso le dará el mejor rendimiento: ejecutará una única rama condicional que se predice correctamente en la mayoría de los casos.

  • Su compilador no tiene conocimiento de la distribución esperada de casos y asumirá una distribución plana.

Desde su compilador es probable que tenga algunas heurísticas buenas en lugar de cuándo un código switch()como una else if()escalera o como una búsqueda en la tabla. Tendería a confiar en su juicio a menos que sepa que la distribución de los casos es parcial.

Entonces, mi consejo es este:

  • Si uno de los casos eclipsa al resto en términos de frecuencia, use una else if()escalera ordenada .

  • De lo contrario, use una switch()declaración, a menos que uno de los otros métodos haga que su código sea mucho más legible. Asegúrese de no comprar una ganancia de rendimiento despreciable con una legibilidad significativamente reducida.

  • Si usó switch()ay aún no está satisfecho con el rendimiento, haga la comparación, pero prepárese para descubrir que esa switch()era la posibilidad más rápida.

cmaster - restablecer monica
fuente
2
Algunos compiladores permiten que las anotaciones le digan al compilador qué caso es más probable que sea cierto, y esos compiladores pueden producir un código más rápido siempre que la anotación sea correcta.
gnasher729
55
una operación O (1) no es necesariamente más rápida en tiempo de ejecución en el mundo real que una O (n) o incluso O (n ^ 20).
cuál es el
2
@whatsisname Por eso dije "para muchos casos". Por la definición de O(1)y O(n)existe un kpara que la O(n)función sea mayor que la O(1)función para todos n >= k. La única pregunta es si es probable que tenga tantos casos. Y sí, he visto switch()declaraciones con tantos casos que una else if()escalera es definitivamente más lenta que una llamada de función virtual o un despacho cargado.
cmaster - reinstalar a monica el
El problema que tengo con esta respuesta es que la única advertencia contra una decisión basada en una ganancia de rendimiento totalmente irrelevante está oculta en algún lugar del siguiente párrafo. Todo lo demás aquí pretende que puede ser una buena idea tomar una decisión sobre las funciones ifvs. switchvs. virtuales basadas en el rendimiento. En casos extremadamente raros puede ser, pero en la mayoría de los casos no lo es.
Doc Brown
7

En general, ¿vale la pena usar funciones virtuales para evitar ramificaciones?

En general si. Los beneficios para el mantenimiento son significativos (pruebas en separación, separación de preocupaciones, modularidad mejorada y extensibilidad).

Pero, en general, qué tan caras son las funciones virtuales frente a la ramificación. Es difícil probar en suficientes plataformas para generalizar, por lo que me preguntaba si alguien tenía una regla general aproximada (encantador si fuera tan simple como 4 ifs es el punto de interrupción)

A menos que haya perfilado su código y sepa que el envío entre sucursales ( la evaluación de las condiciones ) lleva más tiempo que los cálculos realizados ( el código en las ramas ), optimice los cálculos realizados.

Es decir, la respuesta correcta a "qué tan caras son las funciones virtuales frente a la ramificación" es medir y descubrir.

Regla general : a menos que tenga la situación anterior (la discriminación de rama es más costosa que los cálculos de rama), optimice esta parte del código para el esfuerzo de mantenimiento (use funciones virtuales).

Dices que quieres que esta sección se ejecute lo más rápido posible; ¿Qué tan rápido es eso? ¿Cuál es su requerimiento concreto?

En general, las funciones virtuales son más claras y me inclinaría hacia ellas. Pero, tengo varias secciones muy críticas donde puedo cambiar el código de funciones virtuales a ramas. Preferiría tener pensamientos sobre esto antes de emprender esto. (no es un cambio trivial o fácil de probar en múltiples plataformas)

Use funciones virtuales entonces. Esto incluso le permitirá optimizar por plataforma si es necesario, y aún así mantener limpio el código del cliente.

utnapistim
fuente
Después de haber realizado una gran cantidad de programación de mantenimiento, voy a intervenir con un poco de precaución: las funciones virtuales son IMNSHO bastante malas para el mantenimiento, precisamente por las ventajas que enumera. El problema central es su flexibilidad; podrías pegar casi cualquier cosa allí ... y la gente lo hace. Es muy difícil razonar estáticamente sobre el despacho dinámico. Sin embargo, en la mayoría de los casos específicos, el código no necesita toda esa flexibilidad, y eliminar la flexibilidad del tiempo de ejecución puede hacer que sea más fácil razonar sobre el código. Sin embargo, no quiero ir tan lejos como para decir que nunca debe usar el despacho dinámico; eso es absurdo.
Eamon Nerbonne
Las abstracciones más agradables para trabajar son las que son raras (es decir, una base de código tiene solo unas pocas abstracciones opacas), pero muy robustas. Básicamente: no pegue algo detrás de una abstracción de despacho dinámico solo porque tiene una forma similar para un caso particular; solo hágalo si no puede concebir razonablemente alguna razón para preocuparse por alguna distinción entre los objetos que comparten esa interfaz. Si no puede: es mejor tener un ayudante no encapsulante que una abstracción con fugas. Y aún entonces; Existe una compensación entre la flexibilidad de tiempo de ejecución y la flexibilidad de la base de código.
Eamon Nerbonne
5

Las otras respuestas ya proporcionan buenos argumentos teóricos. Me gustaría agregar los resultados de un experimento que realicé recientemente para estimar si sería una buena idea implementar una máquina virtual (VM) utilizando un switchcódigo de operación grande o más bien interpretar el código de operación como un índice en una matriz de punteros de función. Si bien esto no es exactamente lo mismo que una virtualllamada de función, creo que está razonablemente cerca.

He escrito un script de Python para generar aleatoriamente código C ++ 14 para una VM con un tamaño de conjunto de instrucciones elegido aleatoriamente (aunque no de manera uniforme, muestreando el rango bajo de forma más densa) entre 1 y 10000. La VM generada siempre tuvo 128 registros y no RAM. Las instrucciones no son significativas y todas tienen la siguiente forma.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

El script también genera rutinas de despacho utilizando una switchdeclaración ...

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

... y una serie de punteros de función.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

La rutina de envío que se generó se eligió al azar para cada VM generada.

Para la evaluación comparativa, el flujo de códigos operativos fue generado por un std::random_devicemotor aleatorio Mersenne Twister ( ) aleatoriamente sembrado ( std::mt19937_64).

El código para cada VM fue compilado con GCC 5.2.0 usando el -DNDEBUG, -O3y -std=c++14conmutadores. Primero, se compiló utilizando la -fprofile-generateopción y los datos de perfil recopilados para simular 1000 instrucciones aleatorias. El código se volvió a compilar con la -fprofile-useopción que permite optimizaciones basadas en los datos de perfil recopilados.

Luego se ejerció el VM (en el mismo proceso) cuatro veces durante 50 000 000 de ciclos y se midió el tiempo para cada ejecución. La primera ejecución se descartó para eliminar los efectos de caché en frío. El PRNG no se volvió a sembrar entre las ejecuciones para que no realizaran la misma secuencia de instrucciones.

Con esta configuración, se recopilaron 1000 puntos de datos para cada rutina de despacho. Los datos se recopilaron en una APU AMD A8-6600K de cuatro núcleos con caché KiB 2048 con GNU / Linux de 64 bits sin un escritorio gráfico u otros programas en ejecución. A continuación se muestra una gráfica del tiempo promedio de CPU (con desviación estándar) por instrucción para cada VM.

ingrese la descripción de la imagen aquí

A partir de estos datos, podría ganarme la confianza de que usar una tabla de funciones es una buena idea, excepto tal vez por un número muy pequeño de códigos operativos. No tengo una explicación para los valores atípicos de la switchversión entre 500 y 1000 instrucciones.

Todo el código fuente para el punto de referencia, así como los datos experimentales completos y un diagrama de alta resolución se pueden encontrar en mi sitio web .

5gon12eder
fuente
3

Además de la buena respuesta de cmaster, que voté positivamente, tenga en cuenta que los punteros de función son generalmente estrictamente más rápidos que las funciones virtuales. El despacho de funciones virtuales generalmente implica primero seguir un puntero desde el objeto a la vtable, indexar adecuadamente y luego desreferenciar un puntero de función. Entonces, el paso final es el mismo, pero inicialmente hay pasos adicionales. Además, las funciones virtuales siempre toman "esto" como argumento, los punteros de función son más flexibles.

Otra cosa a tener en cuenta: si su ruta crítica involucra un bucle, puede ser útil ordenar el bucle por destino de envío. Obviamente, esto es nlogn, mientras que atravesar el ciclo es solo n, pero si va a atravesar muchas veces, puede valer la pena. Al ordenar por destino de envío, se asegura de que el mismo código se ejecute repetidamente, manteniéndolo caliente en icache, minimizando las pérdidas de caché.

Una tercera estrategia a tener en cuenta: si decide alejarse de las funciones virtuales / punteros de función hacia estrategias if / switch, también puede ser útil cambiar de objetos polimórficos a algo como boost :: variant (que también proporciona el cambio caso en forma de abstracción del visitante). Los objetos polimórficos deben almacenarse mediante un puntero base, por lo que sus datos están en todo el lugar en caché. Esto podría ser una influencia mayor en su ruta crítica que el costo de la búsqueda virtual. Considerando que la variante se almacena en línea como una unión discriminada; Tiene un tamaño igual al tipo de datos más grande (más una constante pequeña). Si sus objetos no difieren demasiado en tamaño, esta es una excelente manera de manejarlos.

En realidad, no me sorprendería si mejorar la coherencia de caché de sus datos tuviera un impacto mayor que su pregunta original, por lo que definitivamente investigaría más sobre eso.

Nir Friedman
fuente
Sin embargo, no sé si una función virtual implica "pasos adicionales". Dado que el diseño de la clase se conoce en tiempo de compilación, es esencialmente lo mismo que un acceso de matriz. Es decir, hay un puntero a la parte superior de la clase, y se conoce el desplazamiento de la función, así que simplemente agréguelo, lea el resultado y esa es la dirección. No mucho sobrecarga.
1
Implica pasos adicionales. La vtable en sí contiene punteros de función, por lo que cuando llega a la vtable, ha alcanzado el mismo estado en el que comenzó con un puntero de función. Todo antes de llegar a la tabla es trabajo extra. Las clases no contienen sus vtables, contienen punteros a vtables, y seguir ese puntero es una desreferencia adicional. De hecho, a veces hay una tercera desreferencia ya que las clases polimórficas generalmente se mantienen mediante un puntero de clase base, por lo que debe desreferenciar un puntero para obtener la dirección vtable (para desreferenciarla ;-)).
Nir Friedman
Por otro lado, el hecho de que la tabla vtable se almacene fuera de la instancia en realidad puede ser útil para la localidad temporal en comparación con, por ejemplo, un montón de estructuras dispares de punteros de función donde cada puntero de función se almacena en una dirección de memoria diferente. En tales casos, una única tabla virtual con un millón de vptrs puede superar fácilmente un millón de tablas de punteros de función (comenzando solo con el consumo de memoria). Puede ser una especie de sacudida aquí, no es tan fácil de descomponer. En general, estoy de acuerdo en que el puntero de función es a menudo un poco más barato, pero no es tan fácil poner uno encima del otro.
Creo que, dicho de otro modo, donde las funciones virtuales comienzan a superar de manera rápida y grosera los punteros de función es cuando tienes un montón de instancias de objetos involucradas (donde cada objeto necesitaría almacenar múltiples punteros de función o un solo vptr). Los punteros de función tienden a ser más baratos si tiene, por ejemplo, solo un puntero de función almacenado en la memoria, que se llamará un montón de veces. De lo contrario, los punteros de función pueden comenzar a ser más lentos con la cantidad de redundancia de datos y errores de caché que resultan de la acumulación de memoria redundante y apuntando a la misma dirección.
Por supuesto, con los punteros de función, también podría almacenarlos en una ubicación central, incluso si son compartidos por un millón de objetos separados para evitar acumular memoria y obtener una gran cantidad de errores de caché. Pero luego comienzan a ser equivalentes a los vpointers, lo que implica el acceso del puntero a una ubicación compartida en la memoria para llegar a las direcciones de funciones reales que queremos llamar. La pregunta fundamental aquí es: ¿almacena la dirección de la función más cerca de los datos a los que está accediendo actualmente o en una ubicación central? vtables solo permite lo último. Los punteros de función permiten ambos sentidos.
2

¿Puedo explicar por qué creo que este es un problema XY ? (No estás solo al preguntarles).

Supongo que su objetivo real es ahorrar tiempo en general, no solo comprender un punto sobre las fallas de caché y las funciones virtuales.

Aquí hay un ejemplo de ajuste de rendimiento real , en software real.

En el software real, se hacen cosas que, sin importar cuán experimentado sea el programador, podrían hacerse mejor. Uno no sabe lo que son hasta que se escribe el programa y se puede ajustar el rendimiento. Casi siempre hay más de una forma de acelerar el programa. Después de todo, para decir que un programa es óptimo, está diciendo que en el panteón de posibles programas para resolver su problema, ninguno de ellos toma menos tiempo. De Verdad?

En el ejemplo al que me vinculé, originalmente tomó 2700 microsegundos por "trabajo". Se solucionó una serie de seis problemas, que iban en sentido antihorario alrededor de la pizza. La primera aceleración eliminó el 33% del tiempo. El segundo eliminó el 11%. Pero tenga en cuenta que el segundo no era del 11% en el momento en que se encontró, era del 16%, porque el primer problema se había ido . Del mismo modo, el tercer problema se amplió del 7,4% al 13% (casi el doble) porque los dos primeros problemas habían desaparecido.

Al final, este proceso de ampliación permitió eliminar todos menos 3,7 microsegundos. Eso es 0.14% del tiempo original, o una aceleración de 730x.

ingrese la descripción de la imagen aquí

Eliminar los problemas inicialmente grandes proporciona una moderada cantidad de aceleración, pero allanan el camino para eliminar problemas posteriores. Estos problemas posteriores podrían haber sido inicialmente partes insignificantes del total, pero después de que se eliminan los primeros problemas, estos pequeños se vuelven grandes y pueden producir grandes aceleraciones. (Es importante comprender que, para obtener este resultado, no se puede perder ninguno, y esta publicación muestra cuán fácilmente pueden ser).

ingrese la descripción de la imagen aquí

¿Fue óptimo el programa final? Probablemente no. Ninguna de las aceleraciones tuvo nada que ver con las fallas de caché. ¿Los errores de caché importan ahora? Tal vez.

EDITAR: Recibo votos negativos de personas que se dirigen a las "secciones muy críticas" de la pregunta del OP. Usted no sabe que algo es "altamente crítico" hasta que sepa qué fracción de tiempo representa. Si el costo promedio de esos métodos llamados es de 10 ciclos o más, con el tiempo, el método de envío a ellos probablemente no sea "crítico", en comparación con lo que realmente están haciendo. Veo esto una y otra vez, donde la gente trata "la necesidad de cada nanosegundo" como una razón para ser centavo y tonto.

Mike Dunlavey
fuente
él ya dijo que tiene varias "secciones muy críticas" que requieren cada último nanosegundo de rendimiento. Entonces, esta no es una respuesta a la pregunta que hizo (incluso si sería una gran respuesta a la pregunta de otra persona)
gbjbaanb
2
@gbjbaanb: Si cada último nanosegundo cuenta, ¿por qué la pregunta comienza con "en general"? Eso no tiene sentido. Cuando los nanosegundos cuentan, no puede buscar respuestas generales, mira lo que hace el compilador, mira lo que hace el hardware, prueba variaciones y mide cada variación.
gnasher729
@ gnasher729 No lo sé, pero ¿por qué termina con "secciones muy críticas"? Supongo que, como slashdot, siempre se debe leer el contenido, ¡y no solo el título!
gbjbaanb
2
@gbjbaanb: Todos dicen que tienen "secciones muy críticas". ¿Cómo lo saben ellos? No sé si algo es crítico hasta que tome, digamos, 10 muestras y las vea en 2 o más de ellas. En un caso como este, si los métodos que se llaman toman más de 10 instrucciones, la sobrecarga de la función virtual probablemente sea insignificante.
Mike Dunlavey
@ gnasher729: Bueno, lo primero que hago es obtener muestras de la pila y, en cada una, examinar qué está haciendo el programa y por qué. Entonces, si pasa todo su tiempo en las hojas del árbol de llamadas, y todas las llamadas son realmente inevitables , no importa lo que hagan el compilador y el hardware. Solo sabe que el envío de métodos es importante si las muestras aterrizan en el proceso de envío de métodos.
Mike Dunlavey