std :: function vs template

Grâce à C ++ 11, nous avons reçu la famille std::function de wrappers foncteurs. Malheureusement, je ne continue à entendre que de mauvaises choses à propos de ces nouveaux ajouts. Le plus populaire est qu’ils sont terriblement lents. Je l’ai testé et ils sont vraiment nuls par rapport aux modèles.

 #include  #include  #include  #include  template  float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; } float calc2(std::function 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(tp2 - tp1); std::cout << d.count() << std::endl; return 0; } 

111 ms contre 1241 ms. Je suppose que c’est parce que les modèles peuvent être bien intégrés, tandis que les function couvrent les composants internes via des appels virtuels.

De toute évidence, les modèles ont leurs problèmes tels que je les vois:

  • ils doivent être fournis en tant qu’en-têtes, ce que vous ne souhaitez peut-être pas faire lorsque vous libérez votre bibliothèque en tant que code fermé,
  • ils peuvent rendre le temps de compilation beaucoup plus long à moins que la politique de type extern template ne soit introduite,
  • Il n’y a pas de moyen propre (au moins connu pour moi) de représenter les exigences (concepts, personne?) d’un modèle, barrer un commentaire décrivant le type de foncteur attendu.

Puis-je donc supposer que les function s peuvent être utilisées comme standard de facto pour les foncteurs de passage, et dans les endroits où de hautes performances sont attendues, des modèles doivent être utilisés?


Modifier:

Mon compilateur est Visual Studio 2012 sans CTP.

En règle générale, si vous êtes confronté à une situation de conception qui vous donne le choix, utilisez des modèles . J’ai insisté sur le mot design car je pense que vous devez vous concentrer sur la distinction entre les cas d’utilisation de std::function et de templates, qui sont assez différents.

En général, le choix des modèles est juste une instance d’un principe plus large: essayez de spécifier autant de contraintes que possible à la compilation . La raison en est simple: si vous détectez une erreur ou une incompatibilité de type, avant même que votre programme ne soit généré, vous n’enverrez pas de programme à votre client.

De plus, comme vous l’avez correctement souligné, les appels aux fonctions de modèle sont résolus statiquement (c’est-à-dire à la compilation), le compilateur dispose donc de toutes les informations pour optimiser et éventuellement aligner le code (ce qui serait impossible si vtable).

Oui, il est vrai que la prise en charge des modèles n’est pas parfaite et que C ++ 11 ne prend toujours pas en charge les concepts. Cependant, je ne vois pas comment std::function pourrait vous sauver à cet égard. std::function n’est pas une alternative aux modèles, mais plutôt un outil pour les situations de conception où les modèles ne peuvent pas être utilisés.

Un tel cas d’utilisation survient lorsque vous devez résoudre un appel à l’exécution en appelant un object appelable qui adhère à une signature spécifique, mais dont le type concret est inconnu à la compilation. C’est généralement le cas lorsque vous avez une collection de rappels de types potentiellement différents , mais que vous devez invoquer de manière uniforme . Le type et le nombre des rappels enregistrés sont déterminés à l’exécution en fonction de l’état de votre programme et de la logique de l’application. Certains de ces callbacks pourraient être des foncteurs, certains pourraient être des fonctions simples, d’autres pourraient être le résultat de la liaison d’autres fonctions à certains arguments.

std::function et std::bind offrent également un idiome naturel pour activer la functional programming en C ++, où les fonctions sont traitées comme des objects et sont naturellement curry et combinées pour générer d’autres fonctions. Bien que ce type de combinaison puisse également être obtenu avec des modèles, une situation de conception similaire est généralement associée à des cas d’utilisation nécessitant de déterminer le type des objects appelables combinés au moment de l’exécution.

Enfin, il existe d’autres situations où std::function est inévitable, par exemple si vous voulez écrire des lambdas récursives ; Cependant, ces ressortingctions sont davantage dictées par les limites technologiques que par les distinctions conceptuelles, à mon avis.

En résumé, concentrez-vous sur la conception et essayez de comprendre quels sont les cas d’utilisation conceptuels de ces deux concepts. Si vous les comparez comme vous l’avez fait, vous les forcez dans une arène à laquelle ils n’appartiennent probablement pas.

Andy Prowl a bien couvert les problèmes de conception. C’est, bien sûr, très important, mais j’estime que la question initiale concerne davantage de problèmes de performances liés à std::function .

Tout d’abord, une remarque rapide sur la technique de mesure: les 11ms obtenus pour calc1 n’ont aucune signification. En effet, en regardant l’assemblage généré (ou en déboguant le code d’assemblage), on peut voir que l’optimiseur de VS2012 est assez intelligent pour se rendre compte que le résultat de l’appel calc1 est indépendant de l’itération et déplace l’appel de la boucle:

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

De plus, il se rend compte que l'appel à calc1 n'a aucun effet visible et supprime l'appel. Par conséquent, le 111ms correspond au temps nécessaire à l'exécution de la boucle vide. (Je suis surpris que l'optimiseur ait gardé la boucle.) Alors, faites attention aux mesures de temps dans les boucles. Ce n'est pas aussi simple que cela puisse paraître.

Comme il a été souligné, l'optimiseur a plus de problèmes pour comprendre std::function et ne déplace pas l'appel hors de la boucle. Donc, 1241ms est une mesure juste pour calc2 .

Notez que std::function est capable de stocker différents types d'objects appelables. Par conséquent, il doit effectuer une magie d'effacement de type pour le stockage. En général, cela implique une allocation de mémoire dynamic (par défaut via un appel à new ). Il est bien connu que cette opération est très coûteuse.

Le standard (20.8.11.2.1 / 5) encapsule les implémentations pour éviter l'allocation dynamic de mémoire pour les petits objects que, heureusement, VS2012 fait (en particulier pour le code d'origine).

Pour avoir une idée de combien de temps il est possible d'obtenir lorsque l'allocation de mémoire est impliquée, j'ai modifié l'expression lambda pour capturer trois float . Cela rend l'object appelable trop gros pour appliquer l'optimisation des petits objects:

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

Pour cette version, l'heure est d'environ 16 000 ms (contre 1241 ms pour le code d'origine).

Enfin, notez que la durée de vie de la lambda englobe celle de la std::function . Dans ce cas, plutôt que de stocker une copie de lambda, std::function pourrait y stocker une "référence". Par "référence", je veux dire un std::reference_wrapper qui est facilement construit par les fonctions std::ref et std::cref . Plus précisément, en utilisant:

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

le temps diminue à environ 1860 ms.

J'ai écrit à ce sujet il y a quelque temps:

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

Comme je l'ai dit dans l'article, les arguments ne s'appliquent pas tout à fait à VS2010 en raison de son faible support pour C ++ 11. Au moment de la rédaction du présent document, seule une version bêta de VS2012 était disponible, mais son support pour C ++ 11 était déjà suffisant.

Avec Clang, il n’y a pas de différence de performance entre les deux

En utilisant clang (3.2, trunk 166872) (-O2 sous Linux), les binarys des deux cas sont en fait identiques .

-Je reviendrai à la fin du post. Mais d’abord, gcc 4.7.2:

Il y a déjà beaucoup de perspicacité, mais je tiens à souligner que le résultat des calculs de calc1 et calc2 ne sont pas les mêmes, à cause de la mise en ligne, etc. Comparez par exemple la sum de tous les résultats:

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

avec calc2 qui devient

 1.71799e+10, time spent 0.14 sec 

alors qu'avec calc1 il devient

 6.6435e+10, time spent 5.772 sec 

c'est un facteur de ~ 40 en différence de vitesse, et un facteur de ~ 4 dans les valeurs. La première différence est beaucoup plus grande que ce que OP a posté (en utilisant Visual Studio). En fait, imprimer la valeur a la fin est également une bonne idée d’empêcher le compilateur de supprimer du code sans résultat visible (règle des as). Cassio Neri l'a déjà dit dans sa réponse. Notez à quel point les résultats sont différents - Il convient de faire attention lors de la comparaison des facteurs de vitesse des codes qui effectuent des calculs différents.

En outre, pour être juste, la comparaison de différentes manières de calculer à plusieurs resockets f (3.3) n'est peut-être pas si intéressante. Si l'entrée est constante, elle ne devrait pas être en boucle. (Il est facile pour l'optimiseur de remarquer)

Si j'ajoute un argument de valeur fourni par l'utilisateur à calc1 et 2, le facteur de vitesse entre calc1 et calc2 se réduit à un facteur de 5, de 40! Avec Visual Studio, la différence est proche d'un facteur 2 et avec le clang, il n'y a pas de différence (voir ci-dessous).

De plus, comme les multiplications sont rapides, parler des facteurs de ralentissement n'est souvent pas très intéressant. Une question plus intéressante est la suivante: quelle est la taille de vos fonctions et s’agit-il du goulot d’étranglement dans un programme réel?

Bruit:

Clang (j'ai utilisé 3.2) a en fait produit des binarys identiques lorsque j'ai basculé entre calc1 et calc2 pour l'exemple de code (posté ci-dessous). Avec l'exemple original posté dans la question, les deux sont également identiques mais ne prennent pas du tout (les boucles sont complètement supprimées comme décrit ci-dessus). Avec mon exemple modifié, avec -O2:

Nombre de secondes à exécuter (meilleur 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 

Les résultats calculés de tous les fichiers binarys sont les mêmes et tous les tests ont été exécutés sur le même ordinateur. Il serait intéressant que quelqu'un avec des connaissances ou des connaissances plus approfondies puisse commenter les optimisations qui ont pu être faites.

Mon code de test modifié:

 #include  #include  #include  template  float calc1(F f, float x) { return 1.0f + 0.002*x+f(x*1.223) ; } float calc2(std::function 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(tp2 - tp1); std::cout << d.count() << std::endl; std::cout << result<< std::endl; return 0; } 

Mettre à jour:

Ajouté vs2015. J'ai aussi remarqué qu'il y avait des conversions double-> float dans calc1, calc2. Les supprimer ne change pas la conclusion pour Visual Studio (les deux sont beaucoup plus rapides mais le rapport est à peu près le même).

Différent n’est pas pareil.

C’est plus lent car cela fait des choses qu’un modèle ne peut pas faire. En particulier, il vous permet d’appeler n’importe quelle fonction pouvant être appelée avec les types d’argument donnés et dont le type de retour est convertible dans le type de retour donné à partir du même code .

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

Notez que le même object fonction, fun , est passé aux deux appels à eval . Il détient deux fonctions différentes .

Si vous n’avez pas besoin de faire cela, alors vous ne devriez pas utiliser std::function .

Vous avez déjà quelques bonnes réponses ici, donc je ne vais pas les contredire, en bref, la comparaison de std :: function à des templates, c’est comme comparer des fonctions virtuelles à des fonctions. Vous ne devriez jamais “préférer” les fonctions virtuelles aux fonctions, mais plutôt utiliser les fonctions virtuelles quand elles correspondent au problème, en déplaçant les décisions de la compilation à l’exécution. L’idée est que plutôt que d’avoir à résoudre le problème en utilisant une solution sur mesure (comme une table de saut), vous utilisez quelque chose qui donne au compilateur une meilleure chance d’optimiser pour vous. Cela aide également les autres programmeurs si vous utilisez une solution standard.

Cette réponse a pour but de consortingbuer, à l’ensemble des réponses existantes, à ce que je pense être une référence plus significative pour le coût d’exécution des appels std :: function.

Le mécanisme std :: function doit être reconnu pour ce qu’il fournit: Toute entité appelable peut être convertie en une fonction std :: de signature appropriée. Supposons que vous ayez une bibliothèque qui adapte une surface à une fonction définie par z = f (x, y), vous pouvez l’écrire pour accepter un std::function et l’utilisateur de la bibliothèque peut convertir facilement toute entité appelable à celle-ci; que ce soit une fonction ordinaire, une méthode d’une instance de classe, ou une lambda, ou tout ce qui est supporté par std :: bind.

Contrairement aux approches de modèles, cela fonctionne sans avoir à recomstackr la fonction de bibliothèque pour différents cas; en conséquence, peu de code compilé supplémentaire est nécessaire pour chaque cas supplémentaire. Cela a toujours été possible, mais cela exigeait des mécanismes peu pratiques, et l’utilisateur de la bibliothèque devrait probablement construire un adaptateur autour de sa fonction pour le faire fonctionner. std :: function construit automatiquement tout adaptateur nécessaire pour obtenir une interface d’appel à l’ exécution commune pour tous les cas, ce qui constitue une nouvelle fonctionnalité très puissante.

À mon avis, il s’agit du cas d’utilisation le plus important de std :: function en ce qui concerne les performances: je suis intéressé par le coût de l’appel d’une fonction std :: plusieurs fois après sa construction, et il doit être une situation où le compilateur ne peut pas optimiser l’appel en connaissant la fonction réellement appelée (c’est-à-dire que vous devez masquer l’implémentation dans un autre fichier source pour obtenir un test correct).

J’ai fait le test ci-dessous, similaire aux OP; mais les principaux changements sont les suivants:

  1. Chaque cas boucle 1 milliard de fois, mais les objects std :: function sont construits une seule fois. J’ai trouvé en regardant le code de sortie que l’opérateur new est appelé lors de la construction d’appels std :: function réels (peut-être pas quand ils sont optimisés).
  2. Le test est divisé en deux fichiers pour éviter toute optimisation indésirable
  3. Mes cas sont les suivants: (a) la fonction est intégrée (b) la fonction est passée par une fonction ordinaire pointeur (c) la fonction est une fonction compatible encapsulée comme std :: function (d) la fonction est une fonction incompatible rendue compatible avec un std :: bind, enveloppé en std :: function

Les résultats que je reçois sont:

  • cas (a) (inline) 1,3 nsec

  • tous les autres cas: 3,3 nsec.

Le cas (d) a tendance à être légèrement plus lent, mais la différence (environ 0,05 nsec) est absorbée par le bruit.

En conclusion, la fonction std :: est comparable à l’utilisation d’un pointeur de fonction, même lorsqu’il existe une simple adaptation de la fonction à la fonction. L’inline est 2 ns plus rapide que les autres mais c’est un compromis attendu puisque l’inline est le seul cas qui est «câblé» au moment de l’exécution.

Quand je lance le code de johan-lundberg sur la même machine, je vois environ 39 nsec par boucle, mais il y a beaucoup plus dans la boucle, y compris le constructeur et le destructeur de la fonction std ::, ce qui est probablement assez élevé car il implique un nouveau et supprimer.

-O2 gcc 4.8.1, vers la cible x86_64 (core i5).

Notez que le code est divisé en deux fichiers pour empêcher le compilateur d’étendre les fonctions où ils sont appelés (sauf dans le cas où il est destiné à le faire).

—– premier fichier source ————–

 #include  // 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 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; } 

----- deuxième fichier source -------------

 #include  #include  #include  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 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(tp2 - tp1); std::cout << d.count() << std::endl; std::cout << result<< std::endl; } return 0; } 

Pour ceux que cela intéresse, voici l'adaptateur que le compilateur a créé pour faire apparaître «mul_by» comme un float (float) - il est appelé lorsque la fonction créée en tant que bind (mul_by, _1,0.5) s'appelle:

 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 

(donc ça aurait pu être un peu plus rapide si j'avais écrit 0.5f dans le bind ...) Notez que le paramètre 'x' arrive dans% xmm0 et rest juste là.

Voici le code dans la zone où la fonction est construite, avant d'appeler test_stdfunc - exécutez via 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, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp) movq std::_Function_base::_Base_manager, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp) call test_stdfunc(std::function const&, int) 

J’ai trouvé vos résultats très intéressants, alors j’ai fait quelques recherches pour comprendre ce qui se passe. Tout d’abord, comme beaucoup l’ont déjà dit sans avoir les résultats de l’effet du calcul, le compilateur optimisera simplement l’état du programme. Deuxièmement, une constante 3.3 étant donnée comme un armement au rappel, je soupçonne qu’il y aura d’autres optimisations en cours. Dans cet esprit, j’ai légèrement modifié votre code de référence.

 template  float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; } float calc2(std::function 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(); } 

Compte tenu de cette modification du code, j'ai compilé avec gcc 4.8 -O3 et a un temps de 330ms pour calc1 et 2702 pour calc2. Donc, l'utilisation du modèle était 8 fois plus rapide, ce nombre me semblait suspect, la vitesse d'une puissance de 8 indique souvent que le compilateur a vectorisé quelque chose. quand j'ai regardé le code généré pour la version des modèles, il était clairement vectoreized

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

Où la version std :: function n'était pas. Cela a du sens pour moi, car avec le modèle, le compilateur sait avec certitude que la fonction ne changera jamais tout au long de la boucle, mais avec la fonction std ::, elle pourrait changer, donc ne peut pas être vectorisée.

Cela m'a amené à essayer autre chose pour voir si le compilateur pouvait effectuer la même optimisation sur la version std :: function. Au lieu de passer une fonction, je crée une fonction std :: en tant que variable globale et je l'appelle.

 float calc3(float i) { return -1.0f * f2(i) + 666.0f; } std::function 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(); } 

Avec cette version, nous voyons que le compilateur a maintenant vectorisé le code de la même manière et que j'obtiens les mêmes résultats.

  • modèle: 330ms
  • std :: fonction: 2702ms
  • std global :: fonction: 330ms

Donc, ma conclusion est que la vitesse brute d'un std :: function par rapport à un foncteur de template est à peu près la même. Cependant, cela rend le travail de l'optimiseur beaucoup plus difficile.