Ce qui suit a été écrit dans une séance en classe suite à une pertinente question des chics Luc Lessard et Pierre-Marc Bérubé, étudiants de la cohorte 07 du DDJV à l'Université de Sherbrooke.
Notez que les équations dans ce document sont affichées à l'aide de MathJax. Notez aussi que j'ai utilisé std::rand(), un générateur de nombres pseudoaléatoires aujourd'hui déprécié, dans le but de simplifier le propos, mais que vous devriez éviter de faire de même dans du code de production; voir ../Divers--cplusplus/prng.html pour en savoir plus.
Pour comprendre la fonction test que nous utiliserons, voir ../AuSecours/Mesurer-le-temps.html
Quand doit-on passer un paramètre par valeur ou par référence-vers-const? En C++, langage où les objets sont souvent manipulés directement plutôt qu'indirectement, comme en Java ou en C#, cette question a un impact sur la vitesse d'exécution des programmes. Ce qui suit escamote évidemment le cas des références non-const, car ce cas n'est pas sémantiquement équivalent à un passage par valeur (la fonction recevant la référence en paramètre peut accéder au référé de manière à le modifier, du moins si l'interface du référé le permet).
Supposons le code à droite, où une instance de la classe X est passée par valeur (fonction f()), ou par référence-vers-const (fonctions g() et h()). Quels sont les enjeux?
Une chose est hautement probable, cependant : si vous écrivez du code comme celui de h(), vous voulez probablement le remplacer par celui de f() qui fait la même chose, mais mieux. |
|
Plusieurs autres enjeux sont à considérer dans le choix d'un type de passage de paramètre ou de l'autre, en particulier le contexte. Si vous avez recours à de la multiprogrammation, et s'il y a un risque que l'objet original soit manipulé en écriture par un autre thread, alors mieux vaut avoir recours à une copie. La qualification const n'est applicable que localement, après tout, et rien n'est plus surprenant que de manipuler une « constante » qui, par l'action d'un autre tronçon de programme, change subitement d'état.
Dans ce petit article, nous nous intéresserons surtout à la question de la taille des objets. À partir de quel moment est-il préférable d'éviter une copie, même pour un POD dont la Sainte-Trinité est implictement correcte? Cette question touche directement une partie de mes étudiants, qui manipulent une grande quantité de D3DXVECTOR3, vecteurs 3D de DirectX, que je remplacerai ci-dessous par un simulacre nommé TiVecteur par souci de simplicité.
Ces objets (ceux de DirectX et le TiVecteur que j'utiliserai) sont des agrégats simples de trois nombres à virgule flottante, donc des POD, auxquels sont ajoutés des constructeurs par souci de simplicité. Pour notre première version, nous utiliserons un TiVecteur à trois dimensions, tel que :
Nous utiliserons des variantes de plus petite taille plus bas, pour fins de comparaison. |
|
Notez au passage la syntaxe que j'ai choisi d'utiliser ici pour les attributs (publics, noms destinés à être utilisés par le code client) et pour les noms des paramètres (le même que celui des attributs, mais en préconstruction ceci ne provoque pas d'ambiguïté) passés au constructeur paramétrique. Sans que je ne sois un fervent défenseur de cette approche, elle est légale et, occasionnellement, préconisée par des penseurs tels qu'Andrew Koenig, dans un cas comme celui-ci où l'objet, de par ses états, est sa propre interface.
Examinons maintenant le code de test auquel nous aurons recours pour réaliser le comparatif en question. Notre première version montrera le code de test en entier, alors que les versions subséquentes se limiteront à présenter les nuances et versions alternatives.
Tout d'abord, pour faciliter la rédaction d'un code de génération d'instances de TiVecteur, j'utiliserai une fonction génératrice (une méthode de classe dans TiVecteur aurait aussi été convenable ici). Pour les besoins de la cause, chaque TiVecteur aura des coordonnées situées inclusivement entre 0.0f et 1.0f. Je créerai chaque coordonnée avec une expression λ pour garder le tout concis. |
|
La fonction par_copie() réalisera une opération sur une copie d'un TiVecteur, alors que la fonction par_ref_to_const() réalisera la même opération sur une référence vers un TiVecteur qualifié const. L'idée ici est de répéter plusieurs fois ces opérations pour constater leur poids en termes de temps d'exécution. |
|
Le programme de test sera simple :
|
|
|
|
Les résultats à l'exécution de ces tests, sur mon ordinateur portatif, vont comme suit (l'affichage de la somme cumulée est purement bidon, évidemment) :
Temps total: 560 ms.
6.71089e+007
Temps total: 718 ms.
6.71089e+007
Appuyez sur une touche pour continuer...
Ainsi, malgré les accès indirects aux attributs, la version référence-vers-const est ici significativement plus rapide que la version recevant un paramètre par valeur. Notez que nous utilisons ici des objets plus gros que la taille des registres, du moins sur une machine 32 bits.
Pour voir l'impact d'une réduction de la taille des objets sur les temps de passage de paramètres, réduisons notre classe TiVecteur de manière à en faire un vecteur 2D. |
|
Adaptons bien sûr nos outils auxiliaires pour tenir compte de ce changement structurel. Pour le reste, notre programme principal ne changera en rien, étant complètement découplé de la structure interne de notre type de données. |
|
Les résultats à l'exécution de ces tests, sur mon ordinateur portatif, vont comme suit :
Temps total: 515 ms.
3.35544e+007
Temps total: 697 ms.
3.35544e+007
Appuyez sur une touche pour continuer...
Encore une fois, la version appliquant la référence-vers-const est nettement plus rapide que celle procédant par valeur.
Enfin, si nous utilisons une copie locale du TiVecteur original dans la version procédant par référence-vers-const, nous obtenons le code à droite. |
|
Les résultats à l'exécution de ces tests, sur mon ordinateur portatif, vont alors comme suit :
Temps total: 695 ms.
3.35544e+007
Temps total: 673 ms.
3.35544e+007
Appuyez sur une touche pour continuer...
Sans grande surprise, la copie manuelle est moins efficace (de peu) que celle, implicite, faite par le compilateur.
À titre informatif, si nous réduisons la taille d'un TiVecteur à celle d'un float, bien qu'il n'ait alors plus d'un vecteur que le nom, qu'adviendra-t-il des performances relatives des deux types de passage de paramètres? Les résultats à l'exécution de ces tests, sur mon ordinateur portatif, vont alors comme suit :
Assez étonamment ici, le type dont le constructeur est trivial (au sens mathématique du terme) et dont la taille est celle d'un registre est plus rapidement manipulé par une fonction lorsque passé par référence-vers-const. |
|
Ce qui suit utilise entre autres les listes de types, une technique de métaprogrammation mise de l'avant par Andrei Alexandrescu. Vous voudrez peut-être lire sur le sujet avant de procéder.
Dans le but de mieux comprendre la dernière gamme de résultats, je me suis permis une série supplémentaire de tests. Dans ce qui suit, nous construirons une liste de types, puis nous testerons des appels de fonctions recevant en paramètre des instances de ces types (a) dans un enrobage simple passé par valeur, (b) dans un enrobage simple passé par référence-vers-const, et (c) par valeur, sans enrobage. Nous obtiendrons ainsi des comparatifs de performances dans chaque cas.
Notez que, dans le cas de types dont la copie est nécessairement coûteuse (par exemple des instances de std::string, qui sont responsables de la gestion de mémoire allouée dynamiquement pour les données entreposées à l'interne), nous adaptons les tests de manière à ce que les tests soient résolus dans un temps raisonnable, et ne provoquent pas un épuisement des ressources disponibles (un manque de mémoire).
Les listes de types nous permettront de construire la liste des types pour lesquels nous souhaitons réaliser des tests, et de réduire par le fait-même la redondance de code dans nos sources. Les macros MAKE_TLIST_n(), pour , sont utilisées pour alléger l'écriture, et ne sont pas nécessaires en tant que telles. Je me suis arrêté à une liste de dix types, mais on aurait pu continuer (tout est question de patience). Notez que cette partie de l'exemple devrait être modernisée pour utiliser une forme variadique. Je le ferai quand j'aurai quelques minutes. |
|
Pour les besoins de la cause, nous utiliserons des Wrap<T> pour enrobages autour de types T, et nous les instancierons à partir de fonctions génératrices. En général, nous créerons des T à partir de valeurs générées pseudo-aléatoirement, mais nous spécialiserons les fonctions génératrices pour les types en fonction desquels cette stratégie un peu simpliste ne convient pas (ici, pour le type std::string, mais nous couvrirons d'autres cas plus bas, pour des points 2D et pour des points 3D). |
|
Les divers types de passages de paramètres testés seront représentés par les fonctions par_valeur(), par_ref_to_const() et pass_thru(). Les deux premières s'appliquent à des Wrap<T> alors que la dernière s'applique à des T bruts. Je n'ai pas couvert le cas const T& brut explicitement, puisqu'il suffit de faire en sortie que le type T passé à pass_thru() soit const & pour le couvrir implicitement. |
|
Pour éviter de polluer l'affichage des résultats par des sorties peu pertinentes, nous utiliserons un flux bidon, global, que j'ai nommé ici nullout. Ce sera le pseudo /dev/null de notre programme. |
|
Sur le compilateur utilisé pour mes tests, les noms obtenus par RTTI pour les types primitifs convenaient à mes besoins, mais ceux pour les templates comme std::string, qui est en fait un alias pour le type suivant :
...ce qui est un peu verbeux. J'ai donc défini des traits pour obtenir des noms plus près de ce que je souhaitais avoir pour fins d'affichage. |
|
Les tests en soi seront plus simples que le code ne le suggère :
Le code à droite peut être simplifié de plusieurs manières, si la chose vous amuse :
Évidemment, le choix d'appels à std::accumulate() pour les tests est arbitraire. Vous pouvez procéder autrement si le coeur vous en dit.
|
|
Étant donné la différence importante entre les temps de tests sur des primitifs et sur des objets responsables d'allouer dynamiquement de la mémoire, j'ai choisi d'ajouter une indirection pour déterminer le nombre de tests à faire, pour réduire le nombre de tests dans le cas de ces types. Le facteur de réduction choisi ici est empirique. |
|
La fonction tester() construit un vecteur standard de Wrap<T>, et base la taille de ce vecteur sur les traits de taille associés au type T, le tout avant de débuter les tests à proprement dit. Les affichages des tests se feront sur std::clog, qui correspondra au fichier choisi en fonction des besoins (ici, dans main()). |
|
Étant donné qu'il n'est pas possible d'appliquer une approche itérative sur une liste de types, nous procéderons par une approche récursive, un type à la fois, à l'aide du type appliquer. Ce type appliquera la méthode (générique) execute() d'une classe oper (du type Op) donnée ur chaque type d'une liste de types. Il est hautement probable que le type Op ici soit un foncteur. Notez que cette partie de l'exemple devrait être modernisée pour s'exprimer sous forme de Fold Expression. Je le ferai quand j'aurai quelques minutes... |
|
Le démarage des tests passera par la classe tests<N>, à travers la méthode execute<T>(). Le nombre de tests sera donc fixé au global, alors que la réalisation de chaque test sera typée. |
|
Pour répondre en partie à la question initiale, en lien avec un agrégat de nombres à virgule flottante, nous utiliserons aussi les types point2D et point3D, qui rejoindront conceptuellement et structurellement les instances de TiVecteur dans les tests initiaux. J'ai adapté les outils de création d'objets (fonctions génératrices creer_wrap<T>()) à ces types, pour que le code de test demeure aussi simple que possible. Le recours à un opérateur + pour chaque type de point tient au fait que cet opérateur est utilisé par nos implémentations des opérations suppléées à std:accumulate() dans le code de test. |
|
Le programme principal, enfin, applique le même nombre de tests (a priori, du moins, donc avant adaptation en fonction de la complexité structurelle du type visé) aux types de la liste de types nommée localement liste. |
|
À l'exécution, sur mon petit ordinateur portatif, j'obtiens :
Type double, sizeof(double): 8, sizeof(Wrap<double>): 8, 40000000 tests
Wrap<double>, par valeur: 390.916 ms.
Wrap<double>, par ref-to-const: 82.9237 ms.
double, par valeur: 78.0677 ms.
double, par ref-to-const: 76.9429 ms.
Type struct point3D, sizeof(struct point3D): 12, sizeof(Wrap<struct point3D>): 12, 40000000 tests
Wrap<struct point3D>, par valeur: 409.007 ms.
Wrap<struct point3D>, par ref-to-const: 413.761 ms.
struct point3D, par valeur: 416.922 ms.
struct point3D, par ref-to-const: 445.863 ms.
Type struct point2D, sizeof(struct point2D): 8, sizeof(Wrap<struct point2D>): 8, 40000000 tests
Wrap<struct point2D>, par valeur: 329.589 ms.
Wrap<struct point2D>, par ref-to-const: 351.761 ms.
struct point2D, par valeur: 330.858 ms.
struct point2D, par ref-to-const: 333.001 ms.
Type string, sizeof(string): 28, sizeof(Wrap<string>): 28, 80000 tests
Wrap<string>, par valeur: 13324.5 ms.
Wrap<string>, par ref-to-const: 4688.57 ms.
string, par valeur: 5024 ms.
string, par ref-to-const: 4637.68 ms.
Type float, sizeof(float): 4, sizeof(Wrap<float>): 4, 40000000 tests
Wrap<float>, par valeur: 219.235 ms.
Wrap<float>, par ref-to-const: 215.845 ms.
float, par valeur: 216.211 ms.
float, par ref-to-const: 217.646 ms.
Type long, sizeof(long): 4, sizeof(Wrap<long>): 4, 40000000 tests
Wrap<long>, par valeur: 42.146 ms.
Wrap<long>, par ref-to-const: 41.2557 ms.
long, par valeur: 40.7634 ms.
long, par ref-to-const: 41.2694 ms.
Type int, sizeof(int): 4, sizeof(Wrap<int>): 4, 40000000 tests
Wrap<int>, par valeur: 40.0836 ms.
Wrap<int>, par ref-to-const: 40.1995 ms.
int, par valeur: 67.3221 ms.
int, par ref-to-const: 39.4935 ms.
Type short, sizeof(short): 2, sizeof(Wrap<short>): 2, 40000000 tests
Wrap<short>, par valeur: 32.8086 ms.
Wrap<short>, par ref-to-const: 50.0433 ms.
short, par valeur: 30.3995 ms.
short, par ref-to-const: 30.1335 ms.
Type char, sizeof(char): 1, sizeof(Wrap<char>): 1, 40000000 tests
Wrap<char>, par valeur: 27.9922 ms.
Wrap<char>, par ref-to-const: 28.09 ms.
char, par valeur: 28.1164 ms.
char, par ref-to-const: 39.2361 ms.
Type double, sizeof(double): 8, sizeof(Wrap<double>): 8, 40000000 tests
Wrap<double>, par valeur: 393.712 ms.
Wrap<double>, par ref-to-const: 82.6255 ms.
double, par valeur: 95.9937 ms.
double, par ref-to-const: 82.3362 ms.
Sous forme tabulaire, on trouve donc :
T |
double
|
char
|
short
|
int
|
long
|
float
|
std::string
|
point2D
|
point3D
|
double
|
Wrap<T> |
393,712 ms.
|
27,9922 ms.
|
32,8086 ms.
|
40,0836 ms.
|
42,146 ms.
|
219,235 ms.
|
13324,5 ms.
|
329,589 ms.
|
409,007 ms.
|
390,916 ms.
|
const Wrap<T>& |
82,6255 ms.
|
28,09 ms.
|
50,0433 ms.
|
40,1995 ms.
|
41,2557 ms.
|
215,845 ms.
|
4688,57 ms.
|
351,761 ms.
|
413,761 ms.
|
82,9237 ms.
|
T |
95,9937 ms.
|
28,1164 ms.
|
30,3995 ms.
|
67,3221 ms.
|
40,7634 ms.
|
216,211 ms.
|
5024 ms.
|
330,858 ms.
|
416,922 ms.
|
78,0877 ms.
|
const T& |
82,3362 ms.
|
39,2361 ms.
|
30,1335 ms.
|
39,4935 ms.
|
41,2694 ms.
|
217,646 ms.
|
4637,68 ms.
|
333,001 ms.
|
445,863 ms.
|
76,9429 ms.
|
sizeof(T) |
8
|
1
|
2
|
4
|
4
|
4
|
28
|
8
|
12
|
8
|
sizeof(Wrap<T>) |
8
|
1
|
2
|
4
|
4
|
4
|
28
|
8
|
12
|
8
|
N |
40000000
|
40000000
|
40000000
|
40000000
|
40000000
|
40000000
|
80000
|
40000000
|
40000000
|
40000000
|
Quelques remarques :
Dans le doute, faites vos propres tests!
Quelques liens pour enrichir votre vision du sujet.