Lier une lambda à une référence constante

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 :

CompilateurOption
d’optimisation
TE avec
const ref
TE avec
valeur brute
ratio const ref /
valeur brute
Clang 11.0-O032.30633.7320.958
Clang 11.0 -O1224.96204.921.097
Clang 11.0 -O23.9982e-64.0088e-60.997
Clang 11.0 -O33.7273e-64.1281e-60.903
GCC 10.2-O0 64.37965.0170.990
GCC 10.2 -O1 11.75411.8710.990
GCC 10.2 -O2 3.7470e-64.0196e-60.932
GCC 10.2 -O3 3.6523e-63.9021e-60.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 :

CompilateurOption
d’optimisation
TB avec
const ref
TE avec
valeur brute
ratio const ref /
valeur brute
Clang 11.0-O00.36290.35101.034
Clang 11.0 -O10.40100.40350.994
Clang 11.0 -O20.37550.37650.997
Clang 11.0 -O30.37450.37351.003
GCC 10.2 -O0 0.39150.39001.004
GCC 10.2 -O1 0.38300.38101.005
GCC 10.2 -O2 0.37650.37750.997
GCC 10.2 -O3 0.37650.37501.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

Laisser un commentaire