Sommaire
Expression Templates pour les nuls
Après un contact prolongé avec Joël Falcou, et pas mal de nœuds aux cerveaux pour arriver à émuler le comportement du broadcasting de Numpy avec des expression templates dans Pythran, j'ai eu l'envie soudaine, un peu folle peut-être, de réécrire un moteur d'expressions template en C++14, mais pour faire simple et didactique.
Mais de quoi zy parle
Les expressions templates sont une maintenant assez vieille technique de C++ qui permet par exemple d'éviter de créer des objets intermédiaires lourds quand on calcule sur des tableaux en utilisant des expressions complexes.
Par exemple, pour le type std::vector<double>
, si on surcharge naïvement (et de façon fort risquée, pas touche aux conteneurs de la lib standard comme ça) les opérateurs +
, *
etc avec un code du genre :
std::vector<double> operator+(std::vector<double> const& left, std::vector<double> const& right) {
assert(left.size() == right.size() && "same size");
std::vector<double> out(left.size());
std::transform(left.begin(), left.end(), right.begin(), out.begin(),
std::plus<double>());
return out;
}
et bien l'évaluation d'une expression du genre a + b * c
va créer un tableau inutile (le résultat de b * c
) et la move semantic ne nous sauve pas totalement. Et on va faire deux boucles, une par opération.
Avec les expressions template, on va plutôt écrire :
add<std::vector<double>, std::vector<double>> operator+(std::vector<double> const& left, std::vector<double> const& right) {
return {left, right};
}
Avec :
template<typename L, typename R>
struct add {
L const& left_;
R const& right_;
add(L const& left, R const& right) : left-(left), right_(right) {}
// ...
};
Ce qui nous donnera comme type de retour de l'expression a + b * c
:
add<std::vector<double>,
mul<std::vector<double>,
std::vector<double>
>
>
En ajoutant un opérateur []
aux types add<...>
et mul<...>
qui fait suivre l'appel aux fils, et une fonction membre size()
qui renvoie, par exemple, la plus petite des tailles des deux fils :
template<typename L, typename R>
struct add {
L const& left_;
R const& right_;
add(L const& left, R const& right) : left-(left), right_(right) {}
auto operator[](size_t i) const { return left_[i] + right_[i]; }
auto size() const { return std::min(left_.size(), right_.size(); }
};
on peut alors écrire le code bâtard mais néanmoins relativement facile à comprendre :
auto expr = a + b * c;
auto n = expr.size();
std::vector<double> res(n);
for(size_t i = 0; i < n; ++i)
res[i] = expr[i];
Et voilà, on a une unique boucle, pas de tableau intermédiaire, c'est la fête et vive les expressions template. Cette technique est très largement utilisée :
Mais elle est assez lourde à mettre en œuvre avec les outils pre-C++11. Je vous propose une solution que je croyais innovante, mais après discussion avec mon mentor sus-nommé, elle n'est qu'ingénieuse, ce qui est somme toute déjà pas mal.
Expressions template à la sauce C++14
Le parcours de l'arbre d'expression de bas en haut à coup d'appels récursifs, ça rappelle fury le design pattern visitor. C'est d'ailleurs une des options proposées par Boost.Proto, le gros pavé pour ceux qui aiment manger des expressions template avec de la crème le matin.
Du coup, proposition didactique (pleins de détails et optimisations possibles sont écartés pour la clarté du propos) : on va associer à chaque type de nœud de notre arbre un tag (une structure vide, un type) et une fonction générique dont le but sera de lancer la mécanique de parcours de l'arbre. Exemple :
struct add_tag {};
template<class A, class B>
auto add(A&& a, B&& b) {
return [=](auto visitor) { return visitor(add_tag{}, a(visitor), b(visitor));};
}
Bon ça parait un peu mystérieux comme ça, le gain en clarté n'est pas (clair ?) évident, mais on renvoie juste, quand on fait appel à la fonction add
, une fonction générique capable d'appliquer un visitor
sur les fils, de bas en haut.
On peut faire la même chose pour les feuilles de l'arbre, ici en distinguant les références vers des objets complexes et les constantes :
struct cst_tag {};
template<class T>
auto cst(T expr) {
return [=](auto visitor) { return visitor(cst_tag{}, expr); };
}
struct ref_tag {};
template<class T>
auto ref(T& expr) {
return [&](auto visitor) { return visitor(ref_tag{}, expr); };
}
Ça fait peu ou prou la même chose, mais avec un tag
différent.
Maintenant que la mécanique est en place, on peut code de façon élégante nos visiteurs ! Par exemple pour obtenir le i-ème élément:
struct evaluator {
evaluator(size_t i) : i_(i) {}
template<class T>
auto operator()(lazy::cst_tag, T c) { return c; }
template<class T>
auto operator()(lazy::ref_tag, T& r) { return r[i_]; }
template<class A, class B>
auto operator()(lazy::mul_tag, A a, B b) { return a * b; }
template<class A, class B>
auto operator()(lazy::add_tag, A a, B b) { return a + b; }
private:
size_t i_;
};
On utilise le tag pour spécialiser l'appel et déterminer le nœud à visiter. L'appel se fait simplement par :
auto expr = add(cst(12), mul(ref(a), ref(b)));
auto expr_0 = expr(evaluator(0));
On peut même spécifier un comportement par défaut, en jouant sur l'ordre de résolution des surcharges :
struct size {
template<class T>
auto operator()(lazy::cst_tag, T c) { return std::numeric_limits<size_t>::max(); }
template<class T>
auto operator()(lazy::ref_tag, T& r) { return r.size(); }
template<class T, class A, class B>
auto operator()(T, A a, B b) { return std::min(a, b); }
};
À la différence de la première approche, plus besoin de modifier les types proxy add
, mul
etc. On peut mettre toute la logique à un endroit, et l'étendre de manière non intrusive. Au final, si on sait vouloir stocker notre expression finale dans un std::vector<...>
, on peut même écrire de façon assez élégante :
template<class E>
auto eval(E const & expr) {
size_t n = expr(size());
std::vector<decltype(expr(evaluator(0)))> res(n);
for(size_t i = 0; i < n; ++i)
res[i] = expr(evaluator(i));
return res;
}
qui fait le bonheur des petits et des grands :
auto expr = add(cst(12), mul(ref(a), ref(b)));
auto evaluated = eval(expr);
Performance
Un petit coup de clang++ -std=c++14 -Ofast -march=native
sur un code utilisant l'expression précédente a fait apparaitre cette séquence d'assembleur:
400ab0: c4 c1 7a 6f 0c 1e vmovdqu (%r14,%rbx,1),%xmm1
400ab6: c4 c1 7a 6f 54 1e 10 vmovdqu 0x10(%r14,%rbx,1),%xmm2
400abd: c4 c1 7a 6f 5c 1e 20 vmovdqu 0x20(%r14,%rbx,1),%xmm3
400ac4: c4 c1 7a 6f 64 1e 30 vmovdqu 0x30(%r14,%rbx,1),%xmm4
400acb: c4 c2 71 40 0c 1f vpmulld (%r15,%rbx,1),%xmm1,%xmm1
400ad1: c4 c2 69 40 54 1f 10 vpmulld 0x10(%r15,%rbx,1),%xmm2,%xmm2
400ad8: c4 c2 61 40 5c 1f 20 vpmulld 0x20(%r15,%rbx,1),%xmm3,%xmm3
400adf: c4 c2 59 40 64 1f 30 vpmulld 0x30(%r15,%rbx,1),%xmm4,%xmm4
400ae6: c5 f1 fe c8 vpaddd %xmm0,%xmm1,%xmm1
400aea: c5 e9 fe d0 vpaddd %xmm0,%xmm2,%xmm2
400aee: c5 e1 fe d8 vpaddd %xmm0,%xmm3,%xmm3
400af2: c5 d9 fe e0 vpaddd %xmm0,%xmm4,%xmm4
400af6: c4 c1 7a 7f 4c 1d 00 vmovdqu %xmm1,0x0(%r13,%rbx,1)
400afd: c4 c1 7a 7f 54 1d 10 vmovdqu %xmm2,0x10(%r13,%rbx,1)
400b04: c4 c1 7a 7f 5c 1d 20 vmovdqu %xmm3,0x20(%r13,%rbx,1)
400b0b: c4 c1 7a 7f 64 1d 30 vmovdqu %xmm4,0x30(%r13,%rbx,1)
400b12: 48 83 c3 40 add $0x40,%rbx
400b16: 48 81 fb 00 03 00 00 cmp $0x300,%rbx
400b1d: 75 91 jne 400ab0 <main+0x70>
Ce qui est franchement cool : le code a été vectorisé, déroulé et il est… vachement propre. On retrouve bien là le principe de costless abstraction si cher au C++. J'♥.
Répéter, c'est apprendre
La petite lib que j'ai écrite pour tester ces idées n'ira pas sur le grand ninternet, c'était juste pour le lulz. Et puis après coup, Il m'a pointé vers https://isocpp.org/blog/2016/05/cppcon-2015-expression-templates-past-present-future-joel-falcou qui contient bien plus de contenu que ce maigre article, qui ne sera finalement qu'une petite introduction et qui aura eu l'avantage de me forcer de coucher sur le clavier (avant d'aller moi-même me coucher) mes idées. Et n'est-ce pas ça, le but d'un journal ?
# Commentaire supprimé
Posté par Anonyme . Évalué à 9.
Ce commentaire a été supprimé par l’équipe de modération.
[^] # Re: Avantage ?
Posté par Guillaum (site web personnel) . Évalué à 7.
Dans ton exemple c'est évident, mais imagine maintenant un pipeline avec une centaines d'opérations de haut niveau elles même définies par une dizaine d'opérations de bas niveau, tout de suite c'est moins facile à inliner à la main, regarde cet exemple :
Un autre example intéressant, imagine que tu ai un pipeline complex et que tu ne veuilles qu'une sous partie de l'image :
Tu ne veux pas calculer toute l'image alors que le
regionOfInterest
limite le calcul à une petite sous partie. Cette technique peut te permettre de compiler un code qui n'effectue le traitement que sur sa sous partie SANS copier une seule image. Note que tu peux aussi spécialiser les templates pour générer du code vectorisé efficace ou des accès mémoire plus sympathiques. (Remarque, ce problème ne se simplifie pas en distribuantregionOfInterest
sur les images d'entrée car ton pipelineComplex peut effectuer un traitement global)[^] # Re: Avantage ?
Posté par serge_sans_paille (site web personnel) . Évalué à 3. Dernière modification le 31 mai 2016 à 13:46.
Je pense que c'est aussi un problème d'API, ou plutôt de compromis API/perf.
Prenons le cas de GMP, il est chouette. Tu as trois entiers multi-précision
a
b
etc
. Tu veux calculer rapidementa + b * c
. Tu peux demander à l'utilisateur d'écrire (et ce le cas en C d'ailleurs),mpz_mul(tmp, b, c); mpz_add(out, a, tmp)
. Le problème c'est que (en plus d'être verbeux) tu introduis un temporaire, et que peut-être qu'il existe une façon de faire un fused multiply add plus efficacement qu'en le cassant en une multiplication et une addition, comme pour la boucle que tu cites où la fusion de boucle a permis, entre autres, de ne pas parcourir deux fois les tableaux. Et tu ne peux pas te permettre d'écrire toutes les fonctionadd_mul
add_add_mul` etc pour chaque optimisation possible.Les expression templates en général permettent de résoudre les deux soucis : au niveau API, l'utilisateur écris juste
a + b * c
et au niveau perf la machinerie (souvent bien plus complexe que ce qui est présentée ici, et bien mieux faite aussi) se charge de l'évaluation efficace de l'expression.Et tu as raison, l'évaluation paresseuse fait parti des effets de bord sympa ;-)
[^] # Re: Avantage ?
Posté par Firwen (site web personnel) . Évalué à 2.
Imagine 5 secondes que a, b et c sont trois matrix de trés grande tailles et que tu enchaînes des opérations beaucoup plus complexe qu'une simple multiplication et soustraction.
L'ordre dans lequel tu appliques tes opérations (suivant la taille de chacune de tes matrix) peut avoir un impact énorme sur le temps de calcul nécessaire.
De même pour la nécessité à copier la mémoire ou non, sur des matrix trés grande, ou lorsque tu as besoin de transposition.
Ce sont des optimisations que presque l’intégralité des bibliothèques désignés pour faire de l'algèbre linéaire mettre en oeuvre en C++: Eigen, Armadillo, blaze, etc, etc.
[^] # Re: Avantage ?
Posté par manawy (site web personnel) . Évalué à 2.
L'avantage est que le plus souvent, le vice est poussé au maximum, et la boucle peut-être supprimé totalement :
Si tu veux voir la différence, compare l'API d'Eigen (http://eigen.tuxfamily.org/dox/group__QuickRefPage.html) avec celle de BLAS (http://www.netlib.org/blas/blasqr.pdf). Les expressions templates permet d'adapter les calculs effectués au mieux.
[^] # Commentaire supprimé
Posté par Anonyme . Évalué à 3.
Ce commentaire a été supprimé par l’équipe de modération.
# C++, où quand le code asm généré est plus lisible
Posté par karchnu . Évalué à 10.
Peut-être que je n'ai pas assez fait de C++ pour voir à quel point c'est génial, mais j'ai l'impression que le code assembleur que tu présentes est plus simple que le code C++. L'expressivité d'un langage ne lui permet-il pas justement de simplifier sa compréhension et d'exprimer de manière plus synthétique et plus sémantique un problème donné ? Avec le C++ j'ai l'impression qu'on répond à un problème technique par une complexité au niveau du langage, justifiée par la résolution d'un problème technique.
Le résultat est très verbeux, peut-on faire plus simple ? Comment se gère ce problème dans les autres langages ?
[^] # Re: C++, où quand le code asm généré est plus lisible
Posté par Guillaum (site web personnel) . Évalué à 2.
D'autres langages vont proposer soit de résoudre le problème au runtime en créant l'arbre d'opération à l’exécution et en générant du code avec un compilateur intégré (JIT), c'est ce que font pas mal d'approches au dessus de numpy, numba, … Tu peux aussi générer du code OpenCL à la volée. Ces approches ont l'avantage de pouvoir en plus avoir une vision sur la taille de la structure, ses caractéristiques et la machine utilisée (Quantité de ram, taille des caches, …), et ainsi pouvoir générer un code plus adapté. En contrepartie c'est encore plus complexe et cela a un coût lors de l’exécution ;)
D'autres langages vont proposer un outil de méta-programmation lors de la compilation qui est "plus lisible" que les templates C++.
D'autres langages ont le support de ce type d'optimisation en natif dans le compilateur (regarde Haskell, les fusion et les rewrite rules), mais à ma connaissance cela ne donne pas ce niveau de satisfaction.
Au final on est dans un domaine de la performance à tout prix et c'est dur, cela fait du code crade et seulement maintenable par des gars qui ont 3 PhD, dont un consacré à la maintenance du code en question, mais bon, c'est ça qui est marrant non ? (Il faut voir que l'exemple donné dans ce journal est "trivial" et "très lisible"… si si… ;(
[^] # Re: C++, où quand le code asm généré est plus lisible
Posté par Nicolas Boulay (site web personnel) . Évalué à 1.
"Le résultat est très verbeux, peut-on faire plus simple ? Comment se gère ce problème dans les autres langages ?"
C'est un problème de meta-programmation. Tu as une forme de code qui tu veux transformer dans une autre forme qui se compile bien. Cette transformation se fait souvent au runtime (tensorflow).
Je ne connais pas d'autres langage qui gère correctement la metaprogrammation qui fonctionne à la compilation. Il me semble que Lisp le fait, et Rust avec ses "macro".
"La première sécurité est la liberté"
[^] # Re: C++, où quand le code asm généré est plus lisible
Posté par Muchacho . Évalué à 3.
Le langage de Jonathan Blow pourra sans doute gérer ça de manière encore plus lisible. La génération de code compile-time se fait avec le même langage.
C'est très long, mais c'est trèèèès intéressant:
https://www.youtube.com/watch?v=2IBr0XZOPsk
Dans la vidéo ci-dessus il crée différentes classes Matrix (4x4) à l'aide d'une fonction template qui prend en paramètre un flag qui va définir comment les différentes structures de matrices vont se multiplier.
Dans un cas concret on peut utiliser différentes matrices 4x4 et savoir à l'avance quels nombres ne vont jamais se multiplier (car toujours à zéro).
En résumé il crée différentes classes de matrice ayant des fonctions "mult(matrice1, matrice2)" différentes avec le même code.
Je me suis toujours demandé pourquoi on ne pouvait pas générer de code avec son code (sans avoir a utiliser un outils externe). Et bien, Jonathan Blow aussi et a décider de ne rien attendre du C/C++ pour ça (ce qui semble assez raisonnable vu l'exemple de ce journal).
[^] # Re: C++, où quand le code asm généré est plus lisible
Posté par whity . Évalué à 1.
D le fait aussi. C’est même sa principale force par rapport à C++ : il le fait avec une syntaxe beaucoup plus facile à appréhender.
Mes commentaires sont en wtfpl. Une licence sur les commentaires, sérieux ? o_0
[^] # Re: C++, où quand le code asm généré est plus lisible
Posté par Firwen (site web personnel) . Évalué à 2. Dernière modification le 01 juin 2016 à 10:38.
Parce que le code assembleur que tu vois en bas n'est pas représentatif du code templated que tu vois plus haut, ni même comparable.
Le code C++ templated que tu vois plus haut est valide pour une infinité de combinaison de mul / add et pour une infinité d'expressions différente.
Alors que le code assembler que tu vois plus bas est valide uniquement pour la simple expression "auto expr = add(cst(12), mul(ref(a), ref(b)));" avec des paramètres donnés.
Si tu gardes ça à l'esprit, alors non le code C++ n'est pas verbeux, il est même extrêmement concis.
Ce que tu as écris en C++ se rapproche au final d'un un "micro-compiler" d'expressions mathématiques. Alors que ce que tu as en assembler est juste une simple opération mathématique sur une range.
C'est là toute la magie de la méta-programmation en C++, faire tout ce que tu peux faire à compile time à compile time pour générer un runtime aussi léger que possible ( en théorie du moins ).
```
# La vraie nouvelle astuce de C++14
Posté par mgaunard . Évalué à 3.
La vraie nouvelle astuce pour les expressions templates et C++14, c'est effectivement d'utiliser les lambdas polymorphes avec capture, comme tu le fais ici.
Par contre moi je ferais pas la récursion dedans, c'est moins générique.
J'ai personnellement commencé à expérimenter avec ce principe pour une nouvelle bibliothèque pour la différentiation automatique d'ordre supérieur.
Par contre malheureusement retourner une lambda ne suffit pas; on veut pouvoir surcharger les opérateurs, donc il faut enrober la lambda dans quelque chose (moi en fait j'ai fait l'inverse, j'ai implémenté un tuple avec une lambda).
# Compatible avec auto ?
Posté par lmg HS (site web personnel) . Évalué à 0.
Si je comprends bien, avec une lambda vous solutionnez le problème des références pendouillantes que l'on a avec les libs usuelles comme Eigen sur
J'avais tenté de résoudre ça avec une approche bien plus tordue.
# Petites précisions
Posté par orsayman . Évalué à 1.
Superbe dépêche… Pour bien le comprendre, j'ai une petite question (mon C++ est tout rouillé et mes design patterns balbutiants): dans l'exemple
qu'est-ce que fait
cst_tag{}
? Si je me reporte au livre du GoF, ça sert à indiquer le type du nœud donc c'est lié aux différentes surcharges deoperator ()
dansevaluator
… Mais comme je ne sais pas ce que faitlazy
, j'ai du mal à tout comprendre…Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.