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 if
el 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)
fuente
Respuestas:
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
switches
oif/else
ramas 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.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 (
switch
o un conjunto deif/else
declaraciones). 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
switch
declaració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:
Nota: por simplicidad, evité
unique_ptr
aquí.... donde
Creature
es 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 predeterminadooperator new
para 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
creatures
contenedor 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.
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.
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:
... 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
humans
en un bucle yother_creatures
en 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):
... donde
virtual_do_something
se 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
switch
declaració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 hacerswitch
.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:
... 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
class
con 20 funciones virtuales versus unstruct
que almacena 20 punteros de función, y ambos se instancian varias veces, la sobrecarga de memoria de cadaclass
instancia en este caso 8 bytes para el puntero virtual en máquinas de 64 bits, mientras que la memoria la sobrecarga delstruct
es 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
structs
punteros 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:
... 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:
... 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).
fuente
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 laelse if()
escalera es unaO(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 deelse 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() ... else
con 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 unaelse 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 esaswitch()
era la posibilidad más rápida.fuente
O(1)
yO(n)
existe unk
para que laO(n)
función sea mayor que laO(1)
función para todosn >= k
. La única pregunta es si es probable que tenga tantos casos. Y sí, he vistoswitch()
declaraciones con tantos casos que unaelse if()
escalera es definitivamente más lenta que una llamada de función virtual o un despacho cargado.if
vs.switch
vs. virtuales basadas en el rendimiento. En casos extremadamente raros puede ser, pero en la mayoría de los casos no lo es.En general si. Los beneficios para el mantenimiento son significativos (pruebas en separación, separación de preocupaciones, modularidad mejorada y extensibilidad).
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?
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.
fuente
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
switch
có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 unavirtual
llamada 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.
El script también genera rutinas de despacho utilizando una
switch
declaración ...... y una serie de punteros de función.
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_device
motor aleatorio Mersenne Twister ( ) aleatoriamente sembrado (std::mt19937_64
).El código para cada VM fue compilado con GCC 5.2.0 usando el
-DNDEBUG
,-O3
y-std=c++14
conmutadores. Primero, se compiló utilizando la-fprofile-generate
opción y los datos de perfil recopilados para simular 1000 instrucciones aleatorias. El código se volvió a compilar con la-fprofile-use
opció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.
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
switch
versió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 .
fuente
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.
fuente
¿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.
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).
¿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.
fuente