template example create c++ templates c++11 std-function

c++ - example - std:: function versus template



template<> c++ (7)

Gracias a C ++ 11 recibimos la familia de funtores std::function . Desafortunadamente, sigo escuchando solo cosas malas sobre estas nuevas incorporaciones. El más popular es que son terriblemente lentos. Lo probé y realmente apestan en comparación con las plantillas.

#include <iostream> #include <functional> #include <string> #include <chrono> template <typename F> float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; } float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; } int main() { using namespace std::chrono; const auto tp1 = system_clock::now(); for (int i = 0; i < 1e8; ++i) { calc1([](float arg){ return arg * 0.5f; }); } const auto tp2 = high_resolution_clock::now(); const auto d = duration_cast<milliseconds>(tp2 - tp1); std::cout << d.count() << std::endl; return 0; }

111 ms vs 1241 ms. Supongo que esto se debe a que las plantillas pueden estar bien alineadas, mientras que las function cubren las partes internas a través de llamadas virtuales.

Obviamente, las plantillas tienen sus problemas cuando los veo:

  • Deben proporcionarse como encabezados, lo cual no es algo que no desee hacer cuando libere su biblioteca como un código cerrado.
  • pueden hacer que el tiempo de compilación sea mucho más largo a menos que se introduzca una política extern template similar a la extern template ,
  • no hay una forma (al menos conocida) limpia de representar los requisitos (¿conceptos, alguien?) de una plantilla, excepto un comentario que describe qué tipo de functor se espera.

¿Puedo por lo tanto asumir que las function se pueden usar como un estándar de facto de los funtores de paso, y en los lugares donde se espera que se usen plantillas de alto rendimiento?

Editar:

Mi compilador es el Visual Studio 2012 sin CTP.


Con Clang no hay diferencia de rendimiento entre los dos

Usando clang (3.2, trunk 166872) (-O2 en Linux), los archivos binarios de los dos casos son en realidad idénticos .

-Volveré a tocar el claxon al final de la publicación. Pero primero, gcc 4.7.2:

Ya hay una gran cantidad de conocimiento en marcha, pero quiero señalar que el resultado de los cálculos de calc1 y calc2 no son los mismos, debido a la alineación, etc. Compare, por ejemplo, la suma de todos los resultados:

float result=0; for (int i = 0; i < 1e8; ++i) { result+=calc2([](float arg){ return arg * 0.5f; }); }

con calc2 que se convierte

1.71799e+10, time spent 0.14 sec

mientras que con calc1 se vuelve

6.6435e+10, time spent 5.772 sec

ese es un factor de ~ 40 en la diferencia de velocidad, y un factor de ~ 4 en los valores. La primera es una diferencia mucho más grande que lo que OP publicó (utilizando Visual Studio). En realidad, imprimir el valor al final también es una buena idea para evitar que el compilador elimine el código sin resultado visible (regla as-if). Cassio Neri ya lo dijo en su respuesta. Tenga en cuenta qué tan diferentes son los resultados: se debe tener cuidado al comparar los factores de velocidad de los códigos que realizan diferentes cálculos.

Además, para ser justos, comparar varias maneras de calcular repetidamente f (3.3) quizás no sea tan interesante. Si la entrada es constante, no debería estar en un bucle. (Es fácil para el optimizador darse cuenta)

Si agrego un argumento de valor proporcionado por el usuario a calc1 y 2, el factor de velocidad entre calc1 y calc2 se reduce a un factor de 5, ¡desde 40! Con Visual Studio la diferencia es cercana a un factor de 2, y con clang no hay diferencia (ver abajo).

Además, como las multiplicaciones son rápidas, hablar de factores de desaceleración a menudo no es tan interesante. Una pregunta más interesante es, ¿qué tan pequeñas son sus funciones, y son estas llamadas el cuello de botella en un programa real?

Sonido metálico:

Clang (utilicé 3.2) en realidad produjo binarios idénticos cuando cambio entre calc1 y calc2 para el código de ejemplo (publicado a continuación). Con el ejemplo original publicado en la pregunta, ambos también son idénticos pero no demoran nada (los bucles se eliminan por completo como se describió anteriormente). Con mi ejemplo modificado, con -O2:

Número de segundos para ejecutar (lo mejor de 3):

clang: calc1: 1.4 seconds clang: calc2: 1.4 seconds (identical binary) gcc 4.7.2: calc1: 1.1 seconds gcc 4.7.2: calc2: 6.0 seconds VS2012 CTPNov calc1: 0.8 seconds VS2012 CTPNov calc2: 2.0 seconds VS2015 (14.0.23.107) calc1: 1.1 seconds VS2015 (14.0.23.107) calc2: 1.5 seconds MinGW (4.7.2) calc1: 0.9 seconds MinGW (4.7.2) calc2: 20.5 seconds

Los resultados calculados de todos los binarios son los mismos, y todas las pruebas se ejecutaron en la misma máquina. Sería interesante que alguien con conocimientos profundos de clang o VS pudiera comentar qué optimizaciones se pudieron haber realizado.

Mi código de prueba modificado:

#include <functional> #include <chrono> #include <iostream> template <typename F> float calc1(F f, float x) { return 1.0f + 0.002*x+f(x*1.223) ; } float calc2(std::function<float(float)> f,float x) { return 1.0f + 0.002*x+f(x*1.223) ; } int main() { using namespace std::chrono; const auto tp1 = high_resolution_clock::now(); float result=0; for (int i = 0; i < 1e8; ++i) { result=calc1([](float arg){ return arg * 0.5f; },result); } const auto tp2 = high_resolution_clock::now(); const auto d = duration_cast<milliseconds>(tp2 - tp1); std::cout << d.count() << std::endl; std::cout << result<< std::endl; return 0; }

Actualizar:

Añadido vs2015. También noté que hay conversiones double-> float en calc1, calc2. Quitarlos no cambia la conclusión para Visual Studio (ambos son mucho más rápidos, pero la relación es casi la misma).


Andy Prowl tiene problemas de diseño bien cubiertos. Esto es, por supuesto, muy importante, pero creo que la pregunta original se refiere a más problemas de rendimiento relacionados con std::function .

En primer lugar, un comentario rápido sobre la técnica de medición: los 11 ms obtenidos para calc1 no tienen ningún significado. De hecho, mirando el ensamblado generado (o depurando el código ensamblador), se puede ver que el optimizador de VS2012 es lo suficientemente inteligente como para darse cuenta de que el resultado de llamar a calc1 es independiente de la iteración y mueve la llamada fuera del ciclo:

for (int i = 0; i < 1e8; ++i) { } calc1([](float arg){ return arg * 0.5f; });

Además, se da cuenta de que llamar a calc1 no tiene ningún efecto visible y descarta la llamada por completo. Por lo tanto, los 111 ms es el tiempo que tarda el ciclo vacío en ejecutarse. (Me sorprende que el optimizador haya mantenido el ciclo). Por lo tanto, tenga cuidado con las mediciones de tiempo en bucles. Esto no es tan simple como podría parecer.

Como se ha señalado, el optimizador tiene más problemas para comprender la std::function y no saca la llamada del bucle. Entonces 1241ms es una medida justa para calc2 .

Tenga en cuenta que std::function puede almacenar diferentes tipos de objetos invocables. Por lo tanto, debe realizar alguna magia de borrado de tipo para el almacenamiento. En general, esto implica una asignación de memoria dinámica (por defecto a través de una llamada a new ). Es bien sabido que esta es una operación bastante costosa.

El estándar (20.8.11.2.1 / 5) codifica las implementaciones para evitar la asignación de memoria dinámica para objetos pequeños que, afortunadamente, VS2012 lo hace (en particular, para el código original).

Para tener una idea de cuánto más lento puede obtenerse cuando se trata de asignación de memoria, he cambiado la expresión lambda para capturar tres float . Esto hace que el objeto invocable sea demasiado grande para aplicar la optimización de objetos pequeños:

float a, b, c; // never mind the values // ... calc2([a,b,c](float arg){ return arg * 0.5f; });

Para esta versión, el tiempo es aproximadamente 16000 ms (en comparación con 1241 ms para el código original).

Finalmente, observe que la vida útil de la lambda encierra la de la std::function . En este caso, en lugar de almacenar una copia de la función lambda, std::function podría almacenar una "referencia" a ella. Por "referencia" me refiero a std::reference_wrapper que se construye fácilmente por las funciones std::ref y std::cref . Más precisamente, al usar:

auto func = [a,b,c](float arg){ return arg * 0.5f; }; calc2(std::cref(func));

el tiempo disminuye a aproximadamente 1860ms.

Escribí sobre eso hace un tiempo:

http://www.drdobbs.com/cpp/efficient-use-of-lambda-expressions-and/232500059

Como dije en el artículo, los argumentos no se aplican completamente para VS2010 debido a su soporte deficiente para C ++ 11. En el momento de la redacción, solo estaba disponible una versión beta de VS2012, pero su soporte para C ++ 11 ya era lo suficientemente bueno para este asunto.


Diferente no es lo mismo.

Es más lento porque hace cosas que una plantilla no puede hacer. En particular, le permite llamar a cualquier función que pueda invocarse con los tipos de argumentos dados y cuyo tipo de devolución sea convertible al tipo de devolución dado desde el mismo código .

void eval(const std::function<int(int)>& f) { std::cout << f(3); } int f1(int i) { return i; } float f2(double d) { return d; } int main() { std::function<int(int)> fun(f1); eval(fun); fun = f2; eval(fun); return 0; }

Tenga en cuenta que el mismo objeto de función, fun , se pasa a ambas llamadas a eval . Tiene dos funciones diferentes .

Si no necesita hacer eso, entonces no debe usar std::function .


En general, si enfrenta una situación de diseño que le permite elegir, use plantillas . Recalqué la palabra diseño porque creo que lo que necesita enfocarse es la distinción entre los casos de uso de std::function y las plantillas, que son bastante diferentes.

En general, la elección de plantillas es solo una instancia de un principio más amplio: trate de especificar tantas restricciones como sea posible en tiempo de compilación . El razonamiento es simple: si puede detectar un error, o una discrepancia de tipo, incluso antes de que se genere su programa, no enviará un programa con errores a su cliente.

Además, como señaló correctamente, las llamadas a funciones de plantilla se resuelven estáticamente (es decir, en tiempo de compilación), por lo que el compilador tiene toda la información necesaria para optimizar y posiblemente en línea el código (lo que no sería posible si la llamada se realizara a través de vtable).

Sí, es cierto que el soporte de plantillas no es perfecto, y C ++ 11 todavía carece de soporte para conceptos; sin embargo, no veo cómo std::function lo salvaría a ese respecto. std::function no es una alternativa a las plantillas, sino una herramienta para situaciones de diseño donde las plantillas no pueden ser utilizadas.

Uno de estos casos de uso surge cuando necesita resolver una llamada en tiempo de ejecución invocando un objeto invocable que se adhiere a una firma específica, pero cuyo tipo concreto es desconocido en tiempo de compilación. Este suele ser el caso cuando tiene una colección de devoluciones de llamadas de tipos potencialmente diferentes , pero que necesita invocar de manera uniforme ; el tipo y número de las devoluciones de llamada registradas se determina en tiempo de ejecución en función del estado de su programa y la lógica de la aplicación. Algunas de esas devoluciones de llamadas podrían ser funcionadores, algunas podrían ser funciones simples, algunas podrían ser el resultado de vincular otras funciones a ciertos argumentos.

std::function y std::bind también ofrecen un modismo natural para permitir la programación funcional en C ++, donde las funciones se tratan como objetos y se curvan de forma natural y se combinan para generar otras funciones. Aunque este tipo de combinación también se puede lograr con plantillas, una situación de diseño similar normalmente se combina con casos de uso que requieren determinar el tipo de objetos llamables combinados en tiempo de ejecución.

Finalmente, hay otras situaciones en las que std::function es inevitable, por ejemplo, si desea escribir lambdas recursivas ; sin embargo, estas restricciones están más dictadas por limitaciones tecnológicas que por distinciones conceptuales, creo.

En resumen, concéntrese en el diseño e intente comprender cuáles son los casos de uso conceptual para estos dos constructos. Si los pones en comparación de la manera en que lo hiciste, los estás forzando a entrar a una arena a la que probablemente no pertenezcan.


Encontré sus resultados muy interesantes, así que investigué un poco para saber qué estaba pasando. En primer lugar, como muchos otros han dicho sin tener los resultados del efecto de cálculo del estado del programa, el compilador simplemente optimizará esto. En segundo lugar, teniendo una constante 3.3 dada como armamento para la devolución de llamada, sospecho que habrá otras optimizaciones sucediendo. Con eso en mente, cambié un poco el código de referencia.

template <typename F> float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; } float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; } int main() { const auto tp1 = system_clock::now(); for (int i = 0; i < 1e8; ++i) { t += calc2([&](float arg){ return arg * 0.5f + t; }, i); } const auto tp2 = high_resolution_clock::now(); }

Dado este cambio en el código compilé con gcc 4.8-O3 y obtuve un tiempo de 330 ms para calc1 y 2702 para calc2. Entonces, usar la plantilla era 8 veces más rápido, este número parecía sospechoso para mí, la velocidad de una potencia de 8 a menudo indica que el compilador ha vectorizado algo. cuando miré el código generado para la versión de las plantillas, estaba claramente vectoreizado

.L34: cvtsi2ss %edx, %xmm0 addl $1, %edx movaps %xmm3, %xmm5 mulss %xmm4, %xmm0 addss %xmm1, %xmm0 subss %xmm0, %xmm5 movaps %xmm5, %xmm0 addss %xmm1, %xmm0 cvtsi2sd %edx, %xmm1 ucomisd %xmm1, %xmm2 ja .L37 movss %xmm0, 16(%rsp)

Donde no estaba la versión de std :: function. Esto tiene sentido para mí, ya que con la plantilla el compilador sabe con certeza que la función nunca cambiará a lo largo del ciclo, pero con la función std :: transferida podría cambiar, por lo tanto no se puede vectorizar.

Esto me llevó a probar algo más para ver si podía hacer que el compilador realizara la misma optimización en la versión std :: function. En lugar de pasar una función, hago una función std :: como var global, y hago que se llame.

float calc3(float i) { return -1.0f * f2(i) + 666.0f; } std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; }; int main() { const auto tp1 = system_clock::now(); for (int i = 0; i < 1e8; ++i) { t += calc3([&](float arg){ return arg * 0.5f + t; }, i); } const auto tp2 = high_resolution_clock::now(); }

Con esta versión, vemos que el compilador ahora ha vectorizado el código de la misma manera y obtengo los mismos resultados de referencia.

  • plantilla: 330ms
  • std :: function: 2702ms
  • global std :: función: 330 ms

Así que mi conclusión es que la velocidad bruta de una función estándar :: frente a la plantilla es prácticamente la misma. Sin embargo, hace que el trabajo del optimizador sea mucho más difícil.


Esta respuesta pretende contribuir al conjunto de respuestas existentes, lo que creo que es un punto de referencia más significativo para el costo de tiempo de ejecución de las llamadas de función estándar.

El mecanismo std :: function debe reconocerse por lo que proporciona: cualquier entidad invocable se puede convertir a una función estándar :: de firma apropiada. Supongamos que tiene una biblioteca que se ajusta a una superficie a una función definida por z = f (x, y), puede escribirla para aceptar una std::function<double(double,double)> , y el usuario de la biblioteca puede convertir fácilmente cualquier entidad invocable a eso; ya sea una función ordinaria, un método de una instancia de clase, o una lambda, o cualquier cosa que sea compatible con std :: bind.

A diferencia de los enfoques de plantilla, esto funciona sin tener que recompilar la función de biblioteca para diferentes casos; en consecuencia, se necesita poco código extra compilado para cada caso adicional. Siempre ha sido posible hacer que esto ocurra, pero solía requerir algunos mecanismos incómodos, y el usuario de la biblioteca probablemente necesitaría construir un adaptador alrededor de su función para hacerlo funcionar. std :: function construye automáticamente cualquier adaptador que sea necesario para obtener una interfaz común de llamadas en tiempo de ejecución para todos los casos, que es una característica nueva y muy poderosa.

En mi opinión, este es el caso de uso más importante para std :: function en lo que respecta al rendimiento: me interesa el costo de llamar a una función std :: muchas veces después de que se ha construido una vez, y necesita una situación en la que el compilador no puede optimizar la llamada conociendo la función que realmente se está llamando (es decir, necesita ocultar la implementación en otro archivo fuente para obtener una referencia adecuada).

Hice la prueba a continuación, similar a la OP; pero los principales cambios son:

  1. Cada caso se repite mil millones de veces, pero los objetos std :: function se construyen solo una vez. He encontrado al mirar el código de salida que se llama ''operador nuevo'' cuando construyo llamadas std :: function reales (tal vez no cuando están optimizadas).
  2. La prueba se divide en dos archivos para evitar una optimización no deseada
  3. Mis casos son: (a) la función está en línea (b) la función se pasa por una función normal puntero (c) la función es una función compatible envuelta como función std :: función (d) es una función incompatible compatible con un estándar :: bind, wrapped as std :: function

Los resultados que obtengo son:

  • caso (a) (en línea) 1.3 nseg

  • todos los demás casos: 3.3 nsec.

El caso (d) tiende a ser un poco más lento, pero la diferencia (aproximadamente 0,05 nseg) se absorbe en el ruido.

La conclusión es que la función std :: es una carga general comparable (en el tiempo de llamada) para usar un puntero de función, incluso cuando hay una simple adaptación de "enlace" a la función real. El en línea es 2 ns más rápido que los demás, pero es una compensación esperada, ya que el en línea es el único caso que está ''cableado'' en tiempo de ejecución.

Cuando ejecuto el código de johan-lundberg en la misma máquina, veo unos 39 nseg por ciclo, pero hay mucho más en el ciclo, incluido el constructor y el destructor real de la función std ::, que probablemente sea bastante alto. ya que implica un nuevo y eliminar.

-O2 gcc 4.8.1, a x86_64 objetivo (core i5).

Tenga en cuenta que el código se divide en dos archivos para evitar que el compilador expanda las funciones a las que se llama (excepto en el caso en el que está destinado).

----- primer archivo fuente --------------

#include <functional> // simple funct float func_half( float x ) { return x * 0.5; } // func we can bind float mul_by( float x, float scale ) { return x * scale; } // // func to call another func a zillion times. // float test_stdfunc( std::function<float(float)> const & func, int nloops ) { float x = 1.0; float y = 0.0; for(int i =0; i < nloops; i++ ){ y += x; x = func(x); } return y; } // same thing with a function pointer float test_funcptr( float (*func)(float), int nloops ) { float x = 1.0; float y = 0.0; for(int i =0; i < nloops; i++ ){ y += x; x = func(x); } return y; } // same thing with inline function float test_inline( int nloops ) { float x = 1.0; float y = 0.0; for(int i =0; i < nloops; i++ ){ y += x; x = func_half(x); } return y; }

----- segundo archivo fuente -------------

#include <iostream> #include <functional> #include <chrono> extern float func_half( float x ); extern float mul_by( float x, float scale ); extern float test_inline( int nloops ); extern float test_stdfunc( std::function<float(float)> const & func, int nloops ); extern float test_funcptr( float (*func)(float), int nloops ); int main() { using namespace std::chrono; for(int icase = 0; icase < 4; icase ++ ){ const auto tp1 = system_clock::now(); float result; switch( icase ){ case 0: result = test_inline( 1e9); break; case 1: result = test_funcptr( func_half, 1e9); break; case 2: result = test_stdfunc( func_half, 1e9); break; case 3: result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9); break; } const auto tp2 = high_resolution_clock::now(); const auto d = duration_cast<milliseconds>(tp2 - tp1); std::cout << d.count() << std::endl; std::cout << result<< std::endl; } return 0; }

Para los interesados, aquí está el adaptador que el compilador construyó para hacer que ''mul_by'' parezca un flotador (float): esto es ''llamado'' cuando se llama a la función creada como bind (mul_by, _1,0.5):

movq (%rdi), %rax ; get the std::func data movsd 8(%rax), %xmm1 ; get the bound value (0.5) movq (%rax), %rdx ; get the function to call (mul_by) cvtpd2ps %xmm1, %xmm1 ; convert 0.5 to 0.5f jmp *%rdx ; jump to the func

(por lo que podría haber sido un poco más rápido si hubiera escrito 0.5f en el enlace ...) Tenga en cuenta que el parámetro ''x'' llega en% xmm0 y simplemente permanece allí.

Aquí está el código en el área donde se construye la función, antes de llamar a test_stdfunc - ejecutar a través de c ++ filt:

movl $16, %edi movq $0, 32(%rsp) call operator new(unsigned long) ; get 16 bytes for std::function movsd .LC0(%rip), %xmm1 ; get 0.5 leaq 16(%rsp), %rdi ; (1st parm to test_stdfunc) movq mul_by(float, float), (%rax) ; store &mul_by in std::function movl $1000000000, %esi ; (2nd parm to test_stdfunc) movsd %xmm1, 8(%rax) ; store 0.5 in std::function movq %rax, 16(%rsp) ; save ptr to allocated mem ;; the next two ops store pointers to generated code related to the std::function. ;; the first one points to the adaptor I showed above. movq std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp) movq std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp) call test_stdfunc(std::function<float (float)> const&, int)


Usted ya tiene algunas buenas respuestas aquí, así que no voy a contradecirlas, en breve comparar std :: function con templates es como comparar funciones virtuales con funciones. Nunca debe "preferir" funciones virtuales a funciones, sino que usa funciones virtuales cuando se ajusta al problema, moviendo las decisiones desde el tiempo de compilación al tiempo de ejecución. La idea es que, en lugar de tener que resolver el problema utilizando una solución a medida (como una mesa de salto), utilice algo que le dé al compilador una mejor oportunidad de optimizarlo. También ayuda a otros programadores, si usa una solución estándar.