c++ enums virtual-functions dispatch micro-optimization

¿Implementación más rápida de un patrón simple, virtual, tipo observador, en c++?



enums virtual-functions (2)

Estoy trabajando duro tratando de implementar una alternativa para los vtables usando enums y un montón de macro magia que realmente está empezando a meterse con mi cerebro. Estoy empezando a pensar que no estoy caminando en el camino correcto ya que el código se está poniendo cada vez más feo y feo, y no será apto para la producción de ninguna manera.

¿Cómo se puede implementar el patrón del siguiente código con la menor cantidad de redireccionamientos / operaciones?

Tiene que hacerse en c ++ estándar, hasta 17.

class A{ virtual void Update() = 0; // A is so pure *¬* }; class B: public A { override void Update() final { // DO B STUFF } } class C: public A { override void Update() final { // DO C STUFF } } // class... int main() { std::vector<A*> vecA{}; // Insert instances of B, C, ..., into vecA for(auto a: vecA) // This for will be inside a main loop a->Update(); // Ridiculous amount of calls per unit of time // Free memory }

PD: Si enum, switch y macros son de hecho la mejor opción, supongo que trataré de refrescar mis caches y crear un mejor diseño.

PSS: Sé que esto es una micro-optimización ... Diablos, necesito nano o incluso pico optimize esto (en sentido figurado), así que simplemente ignoraré cualquier respuesta utilitaria que pueda surgir.


Si realmente necesita el despacho virtual, un método para acelerar el despacho para el mismo método virtual en una lista de objetos de diferentes tipos derivados es usar lo que llamaré type-unswitching .

De forma análoga al bucle sintonizador , esto transforma el bucle único que llama al método en cada objeto en orden en N bucles (para N tipos soportados) que cada uno llama al método en todos los objetos de un tipo específico. Esto evita el costo principal del despacho virtual impredecible: las predicciones equivocadas de las ramas implícitas por la llamada indirecta de una función desconocida e impredecible en el vtable.

La implementación genérica de esta técnica implica un primer paso para dividir los objetos por tipo: la segunda visita utiliza información sobre esta partición, que tiene bucles separados para cada tipo 1 , llamando al método. Por lo general, esto no implica ninguna rama impredecible, si se implementa con cuidado.

En el caso de dos clases derivadas B y C , simplemente puede usar un mapa de bits para almacenar la información del tipo. Aquí hay una implementación de ejemplo, usando los tipos A , B , C del código en la pregunta:

void virtual_call_unswitch(std::vector<A*>& vec) { // first create a bitmap which specifies whether each element is B or C type std::vector<uint64_t> bitmap(vec.size() / 64); for (size_t block = 0; block < bitmap.size(); block++) { uint64_t blockmap = 0; for (size_t idx = block * 64; idx < block * 64 + 64; idx++) { blockmap >>= 1; blockmap |= (uint64_t)vec[idx + 0]->typecode_ << 63; } bitmap[block] = blockmap; } // now loop over the bitmap handling all the B elements, and then again for all the C elements size_t blockidx; // B loop blockidx = 0; for (uint64_t block : bitmap) { block = ~block; while (block) { size_t idx = blockidx + __builtin_ctzl(block); B* obj = static_cast<B*>(vec[idx]); obj->Update(); block &= (block - 1); } blockidx += 64; } // C loop blockidx = 0; for (uint64_t block : bitmap) { while (block) { size_t idx = blockidx + __builtin_ctzl(block); C* obj = static_cast<C*>(vec[idx]); obj->Update(); block &= (block - 1); } blockidx += 64; } }

Aquí, typecode es un campo común en A que identifica el tipo de objeto, 0 para B y 1 para C Algo similar es necesario para que la categorización por tipo sea factible (no puede ser una llamada virtual, ya que hacer una llamada impredecible es lo que estamos tratando de evitar en primer lugar).

Una versión ligeramente optimizada de lo anterior muestra una aceleración de 3.5x para la versión no conmutada a través del bucle normal prácticamente enviado, con la versión virtual marcando en aproximadamente 19 ciclos por despacho, y la versión no conmutada en alrededor de 5.5. Resultados completos:

----------------------------------------------------------------------------- Benchmark Time CPU Iterations ----------------------------------------------------------------------------- BenchWithFixture/VirtualDispatchTrue 30392 ns 30364 ns 23033 128.646M items/s BenchWithFixture/VirtualDispatchFakeB 3564 ns 3560 ns 196712 1097.34M items/s BenchWithFixture/StaticBPtr 3496 ns 3495 ns 200506 1117.6M items/s BenchWithFixture/UnswitchTypes 8573 ns 8571 ns 80437 455.744M items/s BenchWithFixture/StaticB 1981 ns 1981 ns 352397 1.9259G items/s

VirtualDispatchTrue es el bucle simple que llama a Update() en un puntero de tipo A :

for (A *a : vecA) { a->Update(); }

VirtualDispatchFakeB arroja el puntero a B* (independientemente de cuál sea el tipo subyacente) antes de llamar a Update() . Como B::Update() es final, el compilador puede completamente virtualizar y alinear la llamada. Por supuesto, esto no está haciendo lo correcto en absoluto: es tratar cualquier objeto C como B y llamar así al método equivocado (y es totalmente UB), pero está aquí para estimar qué tan rápido podría llamar a los métodos en un vector de punteros si cada objeto era del mismo tipo estáticamente conocido.

for (A *a : vecA) { ((B *)a)->Update(); }

StaticBPtr itera sobre std::vector<B*> lugar de std::vector<A*> StaticBPtr . Como se esperaba, el rendimiento es el mismo que el código "falso B" anterior, ya que el objetivo para la Update() es estáticamente conocido y totalmente ineludible. Está aquí como un control de cordura.

UnswitchTypes es el truco tipo unswitching descrito anteriormente.

StaticB itera sobre un std::vector<B> . Es decir, objetos B asignados contiguamente en lugar de un vector de punteros a los objetos B. Esto elimina un nivel de indirección y muestra algo así como el mejor caso para este diseño de objeto 2 .

La fuente completa está disponible.

Limitaciones

Efectos secundarios y orden

La principal limitación con esta técnica es que el orden de las llamadas a Update() no debería importar. Mientras que Update() todavía se llama una vez en cada objeto, el orden ha cambiado claramente. Siempre que el objeto no actualice ningún estado mutable global o compartido, esto debería ser fácil de satisfacer.

Admite dos tipos

El código anterior solo admite dos tipos, basados ​​en el uso de mapas de bits para registrar información de tipo.

Esta restricción es bastante fácil de eliminar. Primero, el enfoque de mapa de bits se puede extender. Por ejemplo, para admitir 4 tipos, se pueden crear dos mapas de bits similares para los bits correspondientes de cada mapa de bits, esencialmente para un campo de 2 bits que codifica el tipo. Los bucles son similares, excepto que en el bucle externo ellos & y ~ los mapas de bits juntos de manera que en todos los 4 tipos. P.ej:

// type 1 (code 11) for (size_t i = 0; i < bitmap1.size(); i++) { block = bitmap1[i] & bitmap2[i]; ... } // type 2 (code 01) for (size_t i = 0; i < bitmap1.size(); i++) { block = ~bitmap1[i] & bitmap2[i]; ... } ...

Otro enfoque es no usar bitmaps en absoluto, sino simplemente almacenar una matriz de índices por tipo. Cada índice en una matriz apunta a un objeto de ese tipo en la matriz maestra. Básicamente se trata de una clasificación de radix de 1 paso en el código de tipo. Esto probablemente hace que la ordenación de tipo sea un poco más lenta, pero potencialmente acelera la lógica de iteración de bucle (la x & (x - 1) y ctz cosas de ctz desaparecen, a costa de otra indirección).

Número fijo de tipos admitidos

El código anterior admite un número fijo de tipos conocidos en tiempo de compilación (a saber, B y C ). Si se introduce un nuevo tipo, el código anterior se romperá y no podrá llamar a Update() en estos nuevos tipos.

Sin embargo, es sencillo agregar soporte para tipos desconocidos. Simplemente agrupe todos los tipos desconocidos, y luego solo para esos tipos, haga un despacho virtual completo dentro del ciclo (es decir, llame a Update() directamente en el A* ). Pagará el precio completo, pero solo para los tipos que no admitió explícitamente. De esta manera, la técnica se relaciona con la generalidad del mecanismo de despacho virtual.

1 En realidad, solo necesita un bucle por grupo de tipos que todos compartan la misma implementación del método virtual, aunque esto podría ser difícil de implementar de manera genérica ya que esta información no está disponible. Por ejemplo, si las clases Y y Z derivan de X , pero ninguna anula la implementación de algún método virtual de X , entonces todas las X , Y y Z pueden manejarse con el mismo ciclo.

2 Por "diseño de objetos" quiero decir objetos B que aún tienen métodos virtuales y, por lo tanto, un vtable. Si elimina todos los métodos virtuales y se deshace del vtable, las cosas van mucho más rápido ya que el compilador vectoriza la adición a los campos dispuestos de forma compacta. El vtable lo arruina.


Como dijo el primer comentario, tienes un problema XY aquí. La ordenación / reordenación está bien, y tiene muchos objetos, no una gran cantidad de clases diferentes, y no es necesario que admita tipos que su código no conoce en tiempo de compilación. El polimorfismo + herencia virtual es la elección incorrecta .

En su lugar, utilice N contenedores diferentes, uno para cada tipo de objeto, sin direccionamiento indirecto. Dejar al compilador en línea B::Update() en un bucle sobre todos los objetos B es mucho mejor . (Para el ejemplo trivial a continuación de incrementar un miembro int , mi análisis de rendimiento estático al mirar el asm lo pone a aproximadamente 24 veces más rápido en Skylake con los datos calientes en el caché L1D. La auto-vectorización AVX2 versus call en un bucle es realmente eso enorme.)

Si hubiera algún orden requerido entre los objetos, incluso entre diferentes tipos de objetos, entonces sería apropiado algún tipo de polimorfismo o despacho manual. (por ejemplo, si importaba en qué orden vecA , mantener todos los objetos B separados de todos los objetos C no sería equivalente).

Si te preocupa el rendimiento, debes darte cuenta de que hacer que la fuente sea más grande podría simplificar las cosas para el compilador / en la salida de asm. Controlar / despachar según el tipo de cada objeto dentro del bucle interno es costoso. El uso de cualquier tipo de puntero de función o enumeración para despachar por objeto puede sufrir fácilmente errores de trazado cuando se mezclan diferentes objetos.

El bucle por separado sobre múltiples contenedores eleva de manera efectiva ese tipo de verificación del bucle interno y permite que el compilador se desvirtualice. (O mejor, reduce cada objeto para que no necesite un puntero vtable, enum o puntero de función, en primer lugar, porque su tipo es estáticamente conocido).

Escribir un bucle por separado para cada contenedor con un tipo diferente es como desenrollar completamente un bucle sobre diferentes tipos después de izar el tipo despachando fuera del bucle interno. Esto es necesario para el compilador para alinear las llamadas, que desea si hay muchos objetos de cada tipo. Inlining le permite mantener constantes en los registros a través de los objetos, permite la auto-vectorización SIMD en múltiples objetos y simplemente evita la sobrecarga de una llamada de función real. (Tanto la llamada en sí misma como el derrame / recarga de registros.)

Tenía razón en que si necesitaba el despacho por objeto , las funciones virtuales de C ++ son una forma costosa de obtenerlo cuando usa anulaciones final . Paga el mismo costo de tiempo de ejecución que permitiría que su código admita nuevas clases derivadas de tamaño arbitrario que no conocía en tiempo de compilación, pero sin obtener ningún beneficio de eso.

El despacho virtual solo funciona con un nivel de direccionamiento indirecto (por ejemplo, un vector de indicadores como el que está usando), lo que significa que necesita administrar los objetos apuntados de alguna manera, por ejemplo asignándolos del vector<B> poolB y vector<C> poolC . Aunque no estoy seguro de que la mayoría de las implementaciones de vector<> usen realloc() cuando necesitan crecer; la API new/delete no tiene un realloc , por lo que el vector puede copiar cada vez que crece, en lugar de intentar extender la asignación existente en su lugar. Compruebe lo que hace su implementación C ++, ya que podría ser una mierda en comparación con lo que puede hacer con malloc / realloc.

Y por cierto, debería ser posible hacer lo new / delete con RAII sin gastos adicionales para la asignación / desasignación, siempre y cuando todas sus clases sean trivialmente destructibles. (Pero tenga en cuenta que unique_ptr puede vencer a otras optimizaciones para usar el vector de punteros). std::unique_ptr advierte que es UB para destruirlo a través de un puntero a la clase base, por lo que es posible que tenga que rodar el suyo. Aún así, en gcc en x86-64, sizeof(unique_ptr<class C>) es solo 8, por lo que solo tiene un único miembro de puntero. Pero lo que sea, la asignación por separado de trocitos de pequeños objetos apesta, así que no lo hagas de esa manera en primer lugar .

Si necesitas algún tipo de despacho como el título pide

Si los objetos son todos de tamaños similares, entonces realmente desea pasar por encima de objetos, no de punteros a objetos . Eso evitaría la huella de memoria caché adicional de un vector de punteros, y evita la latencia adicional de persecución de puntero que la ejecución fuera de orden debe ocultar para mantener ocupadas las unidades de ejecución. Pero la herencia virtual de C ++ no proporciona ninguna forma compatible con los estándares para obtener el polimorfismo para union upoly { B b; C c; } poly_array[1024]; union upoly { B b; C c; } poly_array[1024]; Puedes hackear esto con reinterpret_cast<> de una manera que probablemente funcione en x86-64 gcc, pero probablemente no deberías hacerlo. Ver el seguimiento de @ BeeOnRope: almacenamiento contiguo de tipos polimórficos . (También una vieja Q & A: polimorfismo C ++ de un objeto en una matriz ).

Si lo necesita, la forma de mayor rendimiento probablemente sea construirlo usted mismo con una enum para indexar una tabla de indicadores de función (o use un switch() si sus funciones pueden alinearse). Si sus funciones no están en línea, switch() a un grupo de case llamadas de función generalmente no se optimiza hasta una tabla de punteros de función, incluso si todos tienen los mismos args (o no args). Por lo general, obtienes una tabla de salto a un bloque de instrucciones de llamada, en lugar de hacer una call indirecta. Entonces hay un salto extra en cada despacho.

C ++ 17 std::visit con std::variant<B, C> (usando herencia no virtual para B y C) parece darle despacho basado en una enum interna. std::visit usa su propia tabla de salto para despachar, incluso con solo 2 tipos posibles, en lugar de delimitarlos y usar una rama condicional. También debe verificar el estado "no inicializado" todo el tiempo. Puede obtener un buen código si lo B *tmp = std::get_if<B>(&my_variant) manualmente con B *tmp = std::get_if<B>(&my_variant) y un __builtin_unreachable() para decirle a gcc que nullptr no es una posibilidad. Pero en ese punto, podrías simplemente lanzar tu propio struct polymorph { enum type; union { B b; C c; }; }; struct polymorph { enum type; union { B b; C c; }; }; (con funciones no virtuales) si no necesita un estado "no inicializado". Relacionado: polimorfismo C ++ de un objeto en una matriz .

En este caso, solo tiene una función, por lo que puede colocar un puntero de función dentro de cada objeto como miembro . Like void (*m_update)(A* this_object) . En la persona que llama, pase un puntero al objeto como un void* o A* , ya que es una función que no es miembro. La implementación de la función reinterpret_cast<C*>(this_object) . (No dynamic_cast : estamos haciendo nuestro propio dispatching, no usando C ++).

Si desea usar B y C en otros contextos donde el miembro del puntero a la función ocuparía espacio sin ningún beneficio, puede mantener los punteros de función en un contenedor separado en lugar de en la clase base . Entonces sería for(i=0..n) funcptrs[i]( &objects[i] ); . Siempre que sus contenedores no se desincronicen, siempre está pasando un puntero a una función que sabe qué hacer con ella. Úselo con union {B b; C c} objects[] union {B b; C c} objects[] (o un vector<union> ).

Puede usar void* si lo desea, especialmente si crea una matriz separada de punteros de función. Entonces los miembros de la unión no necesitan heredar de una base común.

Puede usar std::function<> para almacenar punteros a funciones miembro de instancia, pero en x86-64 gcc que es un objeto de 32 bytes. Es mejor para su espacio de memoria caché usar solo punteros de función normal de 8 bytes y escribir código que sepa pasar un puntero explícito equivalente a this puntero.

Poner un puntero de función en cada objeto puede tomar más espacio que un enum o uint8_t , dependiendo del tamaño / alineación actual. Un pequeño índice entero en una tabla de indicadores de función puede reducir el tamaño de cada instancia de los objetos frente a un miembro del puntero, especialmente para los objetivos de 64 bits. Los objetos más pequeños podrían valer fácilmente el par de instrucciones adicionales para indexar una serie de indicadores de función, y la penalización posiblemente más alta de una prontitud de puntero adicional. Las fallas en la memoria / caché a menudo son un cuello de botella.

Supongo que tienes algún estado por instancia, aunque no muestres ninguno. De lo contrario, ¡un vector de punteros de función ordinarios para las funciones no miembro será mucho más económico!

Comparación aérea:

Eché un vistazo al asm generado por el compilador (gcc y clang targeting x86-64) para algunas maneras de hacerlo.

Fuente de múltiples formas de hacer esto + asm desde x86-64 clang 5.0 en el explorador del compilador Godbolt . Puede pasarlo a gcc o arquitecturas que no sean x86.

class A{ public: virtual void Update() = 0; // A is so pure *¬* }; struct C : public A { int m_c = 0; public: void Update() override final { m_c++; } }; int SC = sizeof(C); // 16 bytes because of the vtable pointer C global_c; // to instantiate a definition for C::Update(); // not inheriting at all gives equivalent asm to making Update non-virtual struct nonvirt_B //: public A { int m_b = 0; void Update() //override final { m_b++; } }; int SB = sizeof(nonvirt_B); // only 4 bytes per object with no vtable pointer void separate_containers(std::vector<nonvirt_B> &vecB, std::vector<C> &vecC) { for(auto &b: vecB) b.Update(); for(auto &c: vecC) c.Update(); }

clang y gcc auto-vectorizan el bucle sobre vecB con AVX2 para procesar 8 elementos int en paralelo, por lo que si no embotella el ancho de banda de la memoria (es decir, caliente en caché L1D), este bucle puede incrementar 8 elementos por ciclo de reloj. Este bucle se ejecuta tan rápido como un bucle sobre un vector<int> ; todo entra en línea y optimiza lejos y es solo un incremento de puntero.

El ciclo sobre vecC solo puede hacer 1 elemento por ciclo de reloj , porque cada objeto tiene 16 bytes (8 bytes de puntero vtable, 4 byte int m_c , 4 bytes de relleno en el siguiente límite de alineación porque el puntero tiene un requisito de alineación 8B). final , el compilador también verifica el puntero vtable para ver si es realmente una C antes de usar la C::Update() , de lo contrario, se distribuye. Es como lo que obtendrías para un ciclo sobre struct { int a,b,c,d; } vecC[SIZE]; struct { int a,b,c,d; } vecC[SIZE]; haciendo vecC[i].c++;

final permitió la desvirtualización completa, pero nuestros datos se mezclan con punteros vtable, por lo que los compiladores solo add [mem], 1 escalar add [mem], 1 que solo puede ejecutarse a 1 por reloj (cuello de botella en 1 por rendimiento de tienda de reloj, independientemente del tamaño de la tienda si está caliente en caché L1D). Esto sobre todo derrota SIMD para este ejemplo. (Con -march=skylake-avx512 , gcc y clang hacen una mezcla o recopilación / dispersión ridículas que es aún más lenta que escalar, en lugar de solo cargar / restaurar todo el objeto y agregarlo con un vector que solo cambia el miembro int . Eso está permitido porque no contiene ningún miembro volátil o atómico, y correría un 2 por reloj con AVX2, o 4 por reloj con AVX512.) Tener objetos de hasta 12 bytes más grandes es un inconveniente importante si son pequeños y tiene una muchos de ellos.

Con múltiples miembros por objeto, esto no necesariamente derrota SIMD, pero aún cuesta espacio en cada objeto, al igual que lo haría un enum o un puntero a la función.

Como mencionó el teorema del eje separador , espero que no planee almacenar los pares float x,y en cada objeto. Array-of-structs básicamente apesta para SIMD, porque necesita una gran cantidad de barajado para usar la x con la y para el mismo objeto . Lo que quiere es std::vector<float> x, y o similar, por lo que su CPU puede cargar 4 x valores en un registro y 4 valores y en otro registro. (O 8 a la vez con AVX).

Vea Diapositivas: SIMD en Insomniac Games (GDC 2015) para una introducción a cómo estructurar sus datos para SIMD, y algunas cosas más avanzadas. Consulte también la wiki de la etiqueta sse para obtener más guías. Además, la wiki de la etiqueta x86 tiene un montón de material de optimización x86 de bajo nivel. Incluso si no vectoriza manualmente nada, con matrices separadas para y hay una buena posibilidad de que el compilador pueda vectorizar automáticamente. (Mire la salida asm, o punto de referencia gcc -O3 -march=native vs. gcc -O3 -march=native -fno-tree-vectorize ). Es posible que necesite -ffast-math Para algunos tipos de vectorización FP.

Despacho virtual de C ++:

Escríbelo como lo haces en la pregunta, con herencia virtual y

std::vector<A*> vecA{}; void vec_virtual_pointers() { for(auto a: vecA) a->Update(); }

Obtenemos este ciclo de clang5.0 -O3 -O3 -march=skylake

# rbx = &vecA[0] .LBB2_1: # do{ mov rdi, qword ptr [rbx] # load a pointer from the vector (will be the this pointer for Update()) mov rax, qword ptr [rdi] # load the vtable pointer call qword ptr [rax] # memory-indirect call using the first entry in the vtable add rbx, 8 # pointers are 8 bytes cmp r14, rbx jne .LBB2_1 # }while(p != vecA.end())

Entonces, el puntero de función final está al final de una cadena de tres cargas dependientes. La ejecución fuera de orden permite que esto se superponga entre las iteraciones (si la derivación predice correctamente), pero eso implica un montón de sobrecarga en instrucciones totales para el front-end, así como en la penalización de escritura errónea. ( call [m] es 3 uops, así que solo el bucle en sí mismo es 8 uops, y solo puede emitir uno por 2 ciclos en Skylake. La llamada / devolución también tiene sobrecarga. Si el destinatario no es totalmente trivial, probablemente no tengamos cuello de botella. en el reenvío a la tienda para presionar / hacer estallar la dirección de retorno. Bucle con función de llamada más rápido que un bucle vacío . (No estoy seguro del rendimiento de las operaciones de almacenamiento / recarga independientes en la misma dirección. Eso normalmente requeriría el cambio de nombre de la memoria, Skylake no funciona, para no obstaculizar eso si el destinatario es pequeño como aquí).

La definición de Clang para C :: Update () es

C::Update(): # @C::Update() inc dword ptr [rdi + 8] ret

Si fuera necesario configurar constantes antes de calcular algo, sería aún más costoso no incluirlo. Por lo tanto, con despacho virtual, esto probablemente se ejecute en aproximadamente uno por cada 3 a 5 relojes, en lugar de aproximadamente 1 miembro por reloj, en Skylake. (O 8 miembros por reloj con AVX2 para la class B no virtual que no desperdicia espacio, y hace que la auto-vectorización funcione bien). Http://agner.org/optimize/ dice que Skylake tiene una capacidad de procesamiento de call 3 por cada reloj, así que digamos una pérdida de rendimiento de 24x cuando los datos están calientes en la caché L1D. Diferentes microarquitecturas serán diferentes, por supuesto. Consulte la wiki de la etiqueta x86 para obtener más información de perfomance x86.

Hack de la Unión:

Probablemente nunca deberías usar esto, pero puedes ver desde el ASM que funcionará en x86-64 con clang y gcc. Hice una serie de uniones, y lo superé:

union upoly { upoly() {} // needs an explicit constructor for compilers not to choke B b; C c; } poly_array[1024]; void union_polymorph() { upoly *p = &poly_array[0]; upoly *endp = &poly_array[1024]; for ( ; p != endp ; p++) { A *base = reinterpret_cast<A*>(p); base->Update(); // virtual dispatch } }

AB y C tienen su vtable al principio, así que creo que esto generalmente funcionará. Pensamos que es básicamente lo mismo, con un paso menos de búsqueda de punteros. (Utilicé una matriz estática en lugar de un vector, ya que mantenía las cosas simples y parecidas a las de C al tiempo de decidir qué lanzar).

lea rdi, [rbx + poly_array] ; this pointer mov rax, qword ptr [rbx + poly_array] ; load it too, first "member" is the vtable pointer call qword ptr [rax] add rbx, 16 ; stride is 16 bytes per object cmp rbx, 16384 ; 16 * 1024 jne .LBB4_1

Esto es mejor y toca menos memoria, pero solo es un poco mejor para la sobrecarga.

std::function from #include <functional>

Puede contener cualquier tipo de cosa invocable. Pero tiene incluso más gastos generales que el envío de vtable, porque está permitido estar en un estado de error si se usa. Entonces, el ciclo interno tiene que verificar cada instancia para eso, y atrapar si es así. Además, sizeof(std::function<void()>); tiene 32 bytes (en x86-64 System V ABI).

#include <functional> // pretty crappy: checks for being possibly unset to see if it should throw(). std::vector<std::function<void()>> vecF{}; void vec_functional() { for(auto f: vecF) f(); } # do { .LBB6_2: # =>This Inner Loop Header: Depth=1 mov qword ptr [rsp + 16], 0 # store a 0 to a local on the stack? mov rax, qword ptr [rbx + 16] test rax, rax je .LBB6_5 # throw on pointer==0 (nullptr) mov edx, 2 # third arg: 2 mov rdi, r14 # first arg: pointer to local stack memory (r14 = rsp outside the loop) mov rsi, rbx # second arg: point to current object in the vector call rax # otherwise call into it with 2 args mov rax, qword ptr [rbx + 24] # another pointer from the std::function<> mov qword ptr [rsp + 24], rax # store it to a local mov rcx, qword ptr [rbx + 16] # load the first pointer again mov qword ptr [rsp + 16], rcx test rcx, rcx je .LBB6_5 # check the first pointer for null again (and throw if null) mov rdi, r14 call rax # call through the 2nd pointer mov rax, qword ptr [rsp + 16] test rax, rax je .LBB6_12 # optionally skip a final call mov edx, 3 mov rdi, r14 mov rsi, r14 call rax .LBB6_12: # in Loop: Header=BB6_2 Depth=1 add rbx, 32 cmp r15, rbx jne .LBB6_2 .LBB6_13: # return add rsp, 32 pop rbx pop r14 pop r15 ret .LBB6_5: call std::__throw_bad_function_call() jmp .LBB6_16 mov rdi, rax call __clang_call_terminate

Entonces hay hasta tres instrucciones de call menos que el puntero sea nullptr. Esto se ve mucho peor que el despacho virtual.

Se ve un poco diferente con clang -stdlib=libc++ , en lugar del libstdc++ predeterminado. ( https://libcxx.llvm.org/ ). Pero todavía tres instrucciones de call en el ciclo interno, con condicionales para omitirlas o lanzar.

A menos que code-gen sea muy diferente para diferentes tipos de function<T> , probablemente ni siquiera valga la pena buscar punteros a funciones de miembros si puede escribir una alternativa más eficiente.