Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre
Contexte
Cette semaine, laissez-moi vous présenter un morceau de code que j’ai pu voir il n’y a pas si longtemps :
#include <vector>
#include <algorithm>
int count_even(const std::vector<int>& v)
{
const auto& my_func = [] (int i)->bool
{
return i%2==0;
};
return std::count_if(std::cbegin(v), std::cend(v), my_func);
}
En voyant cela, VisualStudio n’était pas certain si cela devait compiler ou pas.
En fait, au cours de la compilation, on pouvait voir une erreur du style « my_func
is used but already destroyed » apparaître et disparaître périodiquement, puis disparaître complètement à la fin de la compilation.
Quand j’ai vu tout cela, je me suis dit deux choses :
« Attendez, on peut lier une variable temporaire à une référence constante ? »
et
« Quel est l’intérêt de lier une lambda à une référence constante ? »
C’est à ces deux questions que nous allons répondre aujourd’hui.
Lier une rvalue à une référence constante
Pour la faire courte, oui, on peut lier une un rvalue à une référence constante.
Intuitivement, j’aurais au départ dit que faire cela ne résulterait qu’en une dangling reference, mais ce n’est pas le cas.
Mais en y repensant, c’est plutôt logique, car le langage est pensé de manière intelligente et lorsque vous liez une référence constante à un objet temporaire, l’objet sera mis sur la pile mémoire et vous pourrez toujours y accéder, tant que cette référence constante existera.
Pour illustrer cela, voici deux fonctions :
void foo()
{
const int& i = 1;
const int& j = i+1;
}
void bar()
{
int iv = 1;
const int& i = iv;
int jv = i+1;
const int& j = jv;
}
La fonction foo
lie directement les rvalues à des références constantes, alors que bar
les lient d’abord à des variables avant de lier ces variables (qui sont des lvalues) à des références constantes.
Si on regarde le code assembleur généré par ce code (avec clang 11.0
et -O3
), on obtient :
foo():
push rbp
mov rbp, rsp
mov dword ptr [rbp - 12], 1
lea rax, [rbp - 12]
mov qword ptr [rbp - 8], rax
mov rax, qword ptr [rbp - 8]
mov eax, dword ptr [rax]
add eax, 1
mov dword ptr [rbp - 28], eax
lea rax, [rbp - 28]
mov qword ptr [rbp - 24], rax
pop rbp
ret
bar():
push rbp
mov rbp, rsp
mov dword ptr [rbp - 4], 1
lea rax, [rbp - 4]
mov qword ptr [rbp - 16], rax
mov rax, qword ptr [rbp - 16]
mov eax, dword ptr [rax]
add eax, 1
mov dword ptr [rbp - 20], eax
lea rax, [rbp - 20]
mov qword ptr [rbp - 32], rax
pop rbp
ret
Nonobstant l’alignement sur la pile, ces deux fonctions sont exactement identiques. Ce fait est confirmé par la documentation IBM : Initialization of references (C++ only) – IBM Documentation et, bien entendu, par le standard (The C++11 Programming Language, §7.7.1)
Il s’agit, au final, d’une pratique assez simple à comprendre mais très peu usitée en pratique, et qui est peu référencée sur le web.
La raison à cela est que lier une temporaire à une référence constante au lieu de simplement la lier à une valeur constante – ou même une valeur tout court – à l’air assez inutile en pratique.
Mais l’est-ce réellement ?
Lier une lambda à une référence constante
Pour revenir un peu au contexte initial, la question était : pourquoi lier une lambda à une référence constante est légal et est-ce utile ?
En tant que rappel, revoici l’exemple :
#include <vector>
#include <algorithm>
int count_even(const std::vector<int>& v)
{
const auto& my_func = [] (int i)->bool
{
return i%2==0;
};
return std::count_if(std::cbegin(v), std::cend(v), my_func);
}
Si on met ce code dans C++ Insights (cppinsights.io), on obtient le code suivant :
#include <vector>
#include <algorithm>
int count_even(const std::vector<int, std::allocator<int> > & v)
{
class __lambda_6_27
{
public:
inline /*constexpr */ bool operator()(int i) const
{
return (i % 2) == 0;
}
using retType_6_27 = auto (*)(int) -> bool;
inline /*constexpr */ operator retType_6_27 () const noexcept
{
return __invoke;
};
private:
static inline bool __invoke(int i)
{
return (i % 2) == 0;
}
public:
// inline /*constexpr */ __lambda_6_27(const __lambda_6_27 &) noexcept = default;
// inline /*constexpr */ __lambda_6_27(__lambda_6_27 &&) noexcept = default;
// /*constexpr */ __lambda_6_27() = default;
};
const __lambda_6_27 & my_func = __lambda_6_27{};
return static_cast<int>(std::count_if(std::cbegin(v), std::cend(v), __lambda_6_27(my_func)));
}
Comme vous pouvez le voir (et vous l’aviez peut-être déjà deviné), une lambda fonction est en réalité un foncteur (ici appelé __lambda_6_27
). De ce fait, quand on assigne une lambda à une variable, on appelle en réalité le constructeur du foncteur correspondant. Or, la valeur retournée par cet appel passe nécessairement par une variable temporaire dans ce contexte, ce qui est une rvalue.
Et comme nous l’avons déjà vu, il est légal d’assigner une value à une référence constante.
C’est pour cela que lier une lambda a une référence constante est légal.
Performance et optimisation
Après avoir vu que l’on pouvait lier une lambda à une référence constante, essayons de savoir si on devrait le faire.
Pour cela, analysons les performances en temps d’exécution et de compilation de chaque possibilité.
Temps d’exécution
J’ai utilisé Quick C++ Benchmarks (quick-bench.com) pour benchmarker le temps d’exécution du morceau de code suivant :
std::vector<int> v = {0,1,2,3,4};
static void ConstRef(benchmark::State& state)
{
const auto& l = [](int i)->bool{ return i%2 == 0;};
for (auto _ : state)
{
std::count_if(cbegin(v), cend(v), l);
}
}
BENCHMARK(ConstRef);
static void Plain(benchmark::State& state)
{
auto l = [](int i)->bool{ return i%2 == 0;};
for (auto _ : state)
{
std::count_if(cbegin(v), cend(v), l);
}
}
BENCHMARK(Plain);
J’ai effectué le benchmarking sur clang 11.0 et GCC 10.2, avec chaque option d’optimisation.
Voici les résultats :
Compilateur | Option d’optimisation | TE avec const ref | TE avec valeur brute | ratio const ref / valeur brute |
---|---|---|---|---|
Clang 11.0 | -O0 | 32.306 | 33.732 | 0.958 |
Clang 11.0 | -O1 | 224.96 | 204.92 | 1.097 |
Clang 11.0 | -O2 | 3.9982e-6 | 4.0088e-6 | 0.997 |
Clang 11.0 | -O3 | 3.7273e-6 | 4.1281e-6 | 0.903 |
GCC 10.2 | -O0 | 64.379 | 65.017 | 0.990 |
GCC 10.2 | -O1 | 11.754 | 11.871 | 0.990 |
GCC 10.2 | -O2 | 3.7470e-6 | 4.0196e-6 | 0.932 |
GCC 10.2 | -O3 | 3.6523e-6 | 3.9021e-6 | 0.936 |
La colonne qui nous intéresse est la dernière, qui calcule le rapport de temps d’exécution entre la version liant une référence constante et la version liant une valeur (> 1 signifie que la version liant une référence constante est plus lente que l’autre).
Voici les graphiques associés :
En tout et pour tout on peut voir que même si la version liant un référence constante est globalement meilleure que la version liant une valeur, la différence est toujours inférieure à 10%.
Si le code est dans un goulot d’étranglement (i.e. dans les 20% du principe de Pareto), alors ces 10% peuvent éventuellement faire la différence, mais cela ne permettra jamais d’avoir plus de deux ou trois pourcents de gain dans la globalité d’un code plus massif.
Par contre, si le code n’est pas dans un goulot d’étranglement, il n’y a dans les faits aucune différence notable entre les deux version.
Temps de compilation
Est-ce que lier une lambda à une référence constante affecte le temps de compilation ? Pour répondre à cela, j’ai utilisé C++ Build Benchmarks (build-bench.com)1
Voici les deux codes que j’ai lancé séparément pour les comparer :
#include <vector>
int main()
{
const auto& l = [](int i)->bool{ return i%2 == 0;};
std::vector<int> v= {0,1,2,3,4};
std::count_if(cbegin(v), cend(v), l);
}
et
#include <vector>
int main()
{
auto l = [](int i)->bool{ return i%2 == 0;};
std::vector<int> v= {0,1,2,3,4};
std::count_if(cbegin(v), cend(v), l);
}
Et voici les résultats :
Compilateur | Option d’optimisation | TB avec const ref | TE avec valeur brute | ratio const ref / valeur brute |
---|---|---|---|---|
Clang 11.0 | -O0 | 0.3629 | 0.3510 | 1.034 |
Clang 11.0 | -O1 | 0.4010 | 0.4035 | 0.994 |
Clang 11.0 | -O2 | 0.3755 | 0.3765 | 0.997 |
Clang 11.0 | -O3 | 0.3745 | 0.3735 | 1.003 |
GCC 10.2 | -O0 | 0.3915 | 0.3900 | 1.004 |
GCC 10.2 | -O1 | 0.3830 | 0.3810 | 1.005 |
GCC 10.2 | -O2 | 0.3765 | 0.3775 | 0.997 |
GCC 10.2 | -O3 | 0.3765 | 0.3750 | 1.004 |
Dans tous les cas, on observe une différence inférieures à 4%, et même dans la plupart des cas inférieure à 1%. On peut dire sans risque qu’il n’y a pas de différence significative entre les deux versions.
Conclusion
Il n’y a pas de réel avantage significatif a lier une lambda (ou plus généralement une rvalue) à une référence constante plutôt qu’à une valeur. La plupart du temps, vous préférerez utiliser une simple valeur ou une valeur constante, qui a le bénéfice de ne pas trop alourdir l’écriture avec un symbole supplémentaire.
Dans le cas où vous vous trouvez dans un goulot d’étranglement, vous pourriez considérer l’utilisation d’une référence constante en vue d’optimiser un peu le temps d’exécution, mais dans ce cas là je vous suggère de faire vos propre benchmarks pour déterminer si, dans votre contexte spécifique, c’est effectivement utile.
Merci de votre attention et à la semaine prochaine.
Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre