web-dev-qa-db-fra.com

std :: function vs template

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

#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 contre 1241 ms. Je suppose que cela est dû au fait que les modèles peuvent être bien intégrés, tandis que functions couvre les éléments internes via des appels virtuels.

Évidemment, les modèles ont leurs problèmes tels que je les vois:

  • ils doivent être fournis sous forme d'en-têtes, ce que vous ne voudrez peut-être pas faire lorsque vous publiez votre bibliothèque sous forme de code fermé,
  • ils peuvent rendre le temps de compilation beaucoup plus long à moins que extern template- comme la politique est introduite,
  • il n’existe pas (du moins que je sache) de façon propre de représenter les exigences (concepts, qui que ce soit?) d’un modèle, avec un commentaire décrivant le type de foncteur attendu.

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


Modifier:

Mon compilateur est le Visual Studio 2012 sans CTP.

155
Red XIII

En général, si vous êtes confronté à une situation design qui vous laisse 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 les modèles, qui sont très différents.

En général, le choix des modèles n’est qu’un exemple d’un principe plus large: essayez de spécifier autant de contraintes que possible au moment de la compilation. La logique est simple: si vous pouvez détecter une erreur ou une non-concordance de type, même avant la génération de votre programme, vous ne livrerez pas de programme avec un bug à votre client.

De plus, comme vous l'avez fait remarquer avec justesse, les appels aux fonctions de modèle sont résolus de manière statique (c'est-à-dire au moment de la compilation), de sorte que le compilateur dispose de toutes les informations nécessaires pour optimiser et éventuellement intégrer le code (ce qui ne serait pas possible si l'appel était exécuté via 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 vous sauverait à 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 au moment de l'exécution en appelant un objet appelable qui respecte une signature spécifique, mais dont le type concret est inconnu au moment de la compilation. C'est généralement le cas lorsque vous avez une collection de rappels potentiellement différents types, mais vous devez invoquer uniformément; le type et le numéro des rappels enregistrés sont déterminés au moment de l'exécution en fonction de l'état de votre programme et de la logique de l'application. Certains de ces rappels pourraient être des foncteurs, d'autres des fonctions simples, d'autres pourraient être le résultat de la liaison d'autres fonctions à certains arguments.

std::function et std::bind offre également un idiome naturel pour permettre à la programmation fonctionnelle en C++, où les fonctions sont traitées comme des objets et sont naturellement combinées pour générer d’autres fonctions. Bien que ce type de combinaison puisse également être réalisé avec des modèles, une situation de conception similaire se présente normalement avec des cas d'utilisation nécessitant de déterminer le type des objets 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 lambdas récursif ; Cependant, ces restrictions sont davantage dictées par des limitations technologiques que par des distinctions conceptuelles, je crois.

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.

165
Andy Prowl

Andy Prowl a bien traité les problèmes de conception. Ceci est, bien sûr, très important, mais je pense que la question initiale concerne davantage de problèmes de performances liés à std::function.

Tout d’abord, une petite remarque sur la technique de mesure: les 11 ms obtenus pour calc1 n'a aucune signification. En effet, en regardant l’Assembly généré (ou en corrigeant le code de l’Assembly), on peut constater que l’optimiseur de VS2012 est suffisamment intelligent pour comprendre que le résultat de l’appel de calc1 est indépendant de l'itération et déplace l'appel hors de la boucle:

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

De plus, il réalise que l'appelant calc1 n'a aucun effet visible et interrompt complètement l'appel. Par conséquent, la durée 111ms correspond au temps nécessaire à l'exécution de la boucle vide. (Je suis surpris que l'optimiseur ait gardé la boucle.) Soyez donc prudent avec les 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 mal à comprendre std::function et ne déplace pas l'appel hors de la boucle. 1241ms est donc une mesure juste pour calc2.

Remarquerez que, std::function est capable de stocker différents types d'objets appelables. Par conséquent, il doit effectuer une certaine magie d'effacement de type pour le stockage. Généralement, cela implique une allocation de mémoire dynamique (par défaut via un appel à new). Il est bien connu que cette opération est assez coûteuse.

La norme (20.8.11.2.1/5) prévoit des implémentations afin d'éviter l'allocation dynamique de mémoire pour les petits objets, ce que heureusement VS2012 fait (en particulier pour le code d'origine).

Pour avoir une idée de la lenteur avec l'allocation de mémoire, j'ai changé l'expression lambda pour capturer trois floats. Cela rend l'objet appelable trop volumineux pour appliquer l'optimisation de petit objet:

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

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

Enfin, notez que la durée de vie du lambda enferme celle du std::function. Dans ce cas, plutôt que de stocker une copie du lambda, std::function pourrait 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 1860ms.

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 en C++ 11. Au moment de la rédaction, seule une version bêta de VS2012 était disponible, mais sa prise en charge de C++ 11 était déjà suffisante à cet égard.

86
Cassio Neri

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

Utilisation de clang (3.2, trunk 166872) (-O2 sous Linux), les fichiers binaires des deux cas sont en réalité identiques.

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

Il y a déjà beaucoup d'informations en cours, mais je tiens à souligner que les résultats des calculs de calc1 et calc2 ne sont pas les mêmes, en raison de l'in-line, etc. Comparez par exemple la somme 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

tandis qu'avec calc1 il devient

6.6435e+10, time spent 5.772 sec

c'est un facteur d'environ 40 dans la différence de vitesse et un facteur d'environ 4 dans les valeurs. La première est une différence beaucoup plus grande que celle affichée par OP (avec Visual Studio). En fait, afficher la valeur à la fin est également une bonne idée pour empêcher le compilateur de supprimer du code sans résultat visible (règle as-if). Cassio Neri a déjà dit cela dans sa réponse. Notez à quel point les résultats sont différents - Faites bien attention lorsque vous comparez les facteurs de vitesse des codes qui effectuent des calculs différents.

Pour être juste également, comparer différentes méthodes de calcul répétitif f(3.3) n’est peut-être pas si intéressant. Si l’entrée est constante, elle ne devrait pas être dans une boucle. facile à remarquer pour l'optimiseur)

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

De plus, les multiplications étant 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 ces appels constituent-ils le goulot d'étranglement dans un programme réel?

Bruit:

Clang (j'ai utilisé 3.2) a réellement produit des binaires identiques lorsque je bascule entre calc1 et calc2 pour l'exemple de code (affiché ci-dessous). Avec l'exemple original posté dans la question, les deux sont également identiques mais ne prennent pas de temps du tout (les boucles sont simplement supprimées comme décrit ci-dessus). Avec mon exemple modifié, avec -O2:

Nombre de secondes à exécuter (au mieux 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 binaires sont les mêmes et tous les tests ont été exécutés sur la même machine. Il serait intéressant qu'une personne possédant une connaissance approfondie du langage ou du SV puisse commenter les optimisations éventuellement effectuées.

Mon code de test modifié:

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

Mise à jour:

Ajouté vs2015. J'ai aussi remarqué qu'il y a 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).

37
Johan Lundberg

Différent n'est pas la même chose.

C'est plus lent parce qu'il fait des choses qu'un modèle ne peut pas faire. En particulier, il vous permet d’appeler la fonction toute qui peut être appelée avec les types d’argument donnés et dont le type de retour est convertible en un type de retour donné du même code.

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;
}

Notez que l’objet fonction identique, fun, est transmis aux deux appels à eval. Il contient deux fonctions différentes.

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

13
Pete Becker

Vous avez déjà quelques bonnes réponses ici, donc je ne vais pas les contredire, en bref, comparer std :: function aux modèles équivaut à comparer des fonctions virtuelles à des fonctions. Vous ne devriez jamais "préférer" les fonctions virtuelles aux fonctions, mais vous utilisez plutôt les fonctions virtuelles quand cela vous convient, en déplaçant les décisions du moment de la compilation au temps de 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'optimisation pour vous. Cela aide également les autres programmeurs, si vous utilisez une solution standard.

8
TheAgitator

Cette réponse est destinée à contribuer, à l’ensemble des réponses existantes, à ce que je considère ê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 signature std :: function. Supposons que vous avez 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<double(double,double)>, et l'utilisateur de la bibliothèque peut facilement convertir toute entité appelable. pour que; que ce soit une fonction ordinaire, une méthode d'une instance de classe ou un lambda, ou tout ce qui est supporté par std :: bind.

Contrairement aux approches par modèles, cela fonctionne sans avoir à recompiler la fonction de bibliothèque pour différents cas; Par conséquent, peu de code compilé supplémentaire est nécessaire pour chaque cas supplémentaire. Cela a toujours été possible, mais cela nécessitait jadis des mécanismes compliqués, et l'utilisateur de la bibliothèque aurait probablement besoin de construire un adaptateur autour de sa fonction pour la faire fonctionner. std :: function construit automatiquement tout l'adaptateur nécessaire pour obtenir une interface d'appel commune runtime pour tous les cas, ce qui est une fonctionnalité nouvelle et très puissante.

À mon avis, c’est le cas d’utilisation le plus important de std :: function en ce qui concerne les performances: je suis intéressé par le coût d’appel de std :: function plusieurs fois après sa construction, et il faut Dans une situation où le compilateur est incapable d'optimiser l'appel en sachant que la fonction est effectivement appelée (vous devez masquer l'implémentation dans un autre fichier source pour obtenir un test de performance approprié).

J'ai fait le test ci-dessous, semblable aux PO; mais les principaux changements sont:

  1. Chaque cas boucle 1 milliard de fois, mais les objets std :: function ne sont construits qu'une seule fois. J'ai trouvé en regardant dans le code de sortie que "operator new" est appelé lors de la création des appels std :: function réels (peut-être pas lorsqu'ils sont optimisés en sortie).
  2. Le test est divisé en deux fichiers pour éviter une optimisation indésirable.
  3. Mes cas sont: (a) la fonction est en ligne (b) la fonction est passée par une fonction ordinaire pointeur (c) la fonction est encapsulée comme std :: function (d) la fonction est une fonction incompatible rendue compatible avec un std :: bind, enveloppé en tant que std :: function

Les résultats que je reçois sont:

  • cas (a) (en ligne) 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.

La conclusion est que std :: function est comparable à l’utilisation d’un pointeur de fonction au moment de l’appel, même s’il existe une simple adaptation "liée" à la fonction réelle. L'inline est 2 ns plus rapide que les autres, mais c'est un compromis attendu, car l'inline est le seul cas "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 en a beaucoup plus dans la boucle, y compris le constructeur et le destructeur de std :: function, qui est probablement assez élevé. car il s'agit d'une nouvelle et de 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 de développer les fonctions où ils sont appelés (sauf dans le cas où cela est prévu).

----- premier fichier source --------------

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

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

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

Pour les personnes intéressées, voici l'adaptateur créé par le compilateur pour que "mul_by" ressemble à un float (float) - il est "appelé" lorsque la fonction créée en tant que bind (mul_by, _1,0.5) est appelée:

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

(Cela aurait peut-être été un peu plus rapide si j'avais écrit 0.5f dans le bind ...) Notez que le paramètre 'x' arrive dans% xmm0 et y reste.

Voici le code dans la zone où la fonction est construite, avant d'appeler test_stdfunc - exécuté 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<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)
6
greggo

J'ai trouvé vos résultats très intéressants, j'ai donc creusé un peu pour comprendre ce qui se passait. Tout d’abord, comme beaucoup d’autres l’ont dit, sans les résultats de l’effet de calcul, l’état du programme permet au compilateur de l’optimiser. Deuxièmement, le fait d'avoir une constante de 3,3 pour armer le rappel, je pense 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 <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();
}

Compte tenu de cette modification du code, j’ai compilé avec gcc 4.8 -O3 et obtenu un temps de 330 ms pour calc1 et 2702 pour calc2. Donc, utiliser le modèle était 8 fois plus rapide, ce nombre me paraissait 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 vectoréisé

.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 de 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 au cours de la boucle mais qu'avec le std :: function transmis, elle pourrait changer, elle ne peut donc pas être vectorisée.

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

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();
}

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 :: function: 2702ms
  • global std :: function: 330ms

Donc, ma conclusion est que la vitesse brute d’une fonction std :: function par rapport à un modèle est pratiquement la même. Cependant, cela rend le travail de l'optimiseur beaucoup plus difficile.

4
Joshua Ritterman