L’appareil de Duff en 2021

Article original : Duff’s device in 2021 | Belay the C++ (belaycpp.com)
Traductrice : ChloĂ© Lourseyre

Cette annĂ©e Ă  la CppCon, Walter E. Brown a donnĂ© un Lightning Talk (un confĂ©rence-Ă©clair) sur l’appareil de Duff (je mettrais un lien YouTube vers la vidĂ©o correspondante dès qu’elle sortira).

L’appareil de Duff est un modèle assez vieux et je me suis dit : « À quel point est-il encore utile en 2021, avec le C++20 et tout ? ».

Ainsi naquit cet article

C’est quoi, l’appareil de Duff ?

Ce que j’appelle l’appareil de Duff renvoie Ă  ce qu’on appelle Duff’s device en anglais. Ce mĂ©canisme a Ă©tĂ© nommĂ© d’après son crĂ©ateur Tom Duff. Il correspond Ă  une mĂ©thode de dĂ©roulage de boucle manuelle.

Le dĂ©roulage de boucle (ou loop unrolling) est une technique d’optimisation visant Ă  rĂ©duire de temps d’exĂ©cution d’une boucle en « dĂ©roulant » manuellement son contenu afin de rĂ©duire le nombre de contrĂ´le de boucle effectuĂ©s. Le tout se fait au dĂ©triment de la taille du binaire gĂ©nĂ©rĂ©.

Le principe de l’appareil de Duff est d’exĂ©cuter plusieurs fois le contenu de la boucle Ă  la suite (gĂ©nĂ©ralement entre quatre et huit fois) au lieu d’une seule fois, ce qui permet de ne faire le contrĂ´le de boucle qu’une fois toutes les quatre Ă  huit computations.

Donc au lieu de faire ceci:

void execute_loop(int & data, const size_t loop_size)
{
    for (int i = 0 ; i < loop_size ; ++i)
    {
        computation(data);
    }
}

On cherche Ă  faire quelque chose ressemblant un peu Ă  cela:

void execute_loop(int & data, const size_t loop_size)
{
    for (int i = 0 ; i < loop_size/4 ; ++i)
    {
        computation(data);
        computation(data);
        computation(data);
        computation(data);
    }
}

Cependant, vous l’aurez peut-ĂŞtre remarquĂ©, mais si loop_size n’est pas un multiple de 4, alors le nombre de computation() effectuĂ© n’est pas le bon. Pour palier Ă  ce problème, l’appareil de Duff utilise la capacitĂ© du switch Ă  « passer Ă  travers » (ce qu’on appelle le switch fallthrough et qui ne peut pas avoir de traduction satisfaisante). Concrètement, ça ressemble Ă  ceci:

void execute_loop(int & data, const size_t loop_size)
{
    size_t i = 0;
    switch(loop_size%4)
    {
        do{
            case 0: computation(data);
            case 3: computation(data);
            case 2: computation(data);
            case 1: computation(data);
            ++i;
        } while (i < (loop_size+3)/4);
    }
}

Ce code est un peu plus Ă©trange que ce que vous avez l’habitude de voir, alors laissez-moi l’expliquer.

Au dĂ©but de la fonction, on entre dans le switch et on vĂ©rifie alors le modulo de loop_size. En fonction du rĂ©sultat, on arrive dans un des quatre case. Puis, grâce Ă  ce « passage Ă  travers », on fait un nombre de computation() diffĂ©rent en fonction du case dans lequel on est tombĂ©s. Cela permet de rectifier le problème de divisibilitĂ© par 4 qu’on a rencontrĂ© juste avant.

Ensuite, on arrive sur le while. Comme techniquement le switch nous a envoyĂ© dans une boucle do while(), alors l’exĂ©cution retourne au do et on continue la boucle normalement.

Après la première occurrence, les labels de type case N sont ignorés, donc ça fait comme si on passait à travers à chaque fois.

Vous pouvez vĂ©rifier les calculs : cela fait qu’on rĂ©alise exactement loop_size computations.

Est-ce que l’appareil de Duff en vaut la peine ?

L’appareil de Duff vient d’une autre Ă©poque, d’une autre ère (et mĂŞme d’un autre langage), donc ma première rĂ©action a Ă©tĂ© : « Ce genre de mĂ©canisme est probablement contre-productif, autant laisser le compilateur optimiser lui-mĂŞme les boucles. »

Mais je voulais une preuve tangible que cette approche Ă©tait la bonne. Quoi de mieux que quelques benchmarks pour obtenir une telle preuve ?

Benchmarks

Pour faire ce benchmark, j’ai utilisĂ© le code suivant : Quick C++ Benchmarks – Duff’s device (quick-bench.com).

Voici les résultats1 :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og7.5657e+47.2965e+4– 3.6%
Clang 12.0-O17.0786e+47.3221e+4+ 3.4%
Clang 12.0-O21.2452e-11.2423e-1– 0.23%
Clang 12.0-O31.2620e-11.2296e-1– 2.6%
GCC 10.3-Og4.7117e+44.7933e+4+ 1.7%
GCC 10.3-O17.0789e+47.2404e+4+ 2.3%
GCC 10.3-O24.1516e-64.1224e-6– 0.70%
GCC 10.3-O34.0523e-64.0654e-6+ 0.32%

Dans cette situation, on peut voir que la diffĂ©rence est insignifiante (3.5% sur un benchmark c’est peu, dans un code intĂ©grĂ© cette diffĂ©rence serait diluĂ©e dans le reste du code). De plus, le cĂ´tĂ© duquel penche la balance varie d’un compilateur et d’une option Ă  l’autre.

Après cela, j’ai utilisĂ© une version plus simple du computation(), plus facile Ă  optimiser pour le compilateur.

Cela donne ce résultat :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og5.9463e+45.9547e+4+ 0.14%
Clang 12.0-O15.9182e+45.9235e+4+ 0.09%
Clang 12.0-O24.0450e-61.2233e-1+ 3 000 000%
Clang 12.0-O34.0398e-61.2502e-1+ 3 000 000%
GCC 10.3-Og4.2780e+44.0090e+4– 6.3%
GCC 10.3-O11.1299e+45.9238e+4+ 420%
GCC 10.3-O23.8900e-63.8850e-6– 0.13%
GCC 10.3-O35.3264e-64.1162e-6– 23%

C’est intĂ©ressant, car on observe que Clang peut, de lui-mĂŞme, grandement optimiser la boucle simple sans arriver Ă  optimiser de la mĂŞme manière l’appareil de Duff (avec les options -O2 et -O3 la boucle simple est 30 000 fois plus rapide ; c’est parce que la boucle est entièrement optimisĂ©e en une simple addition, mais considère que l’appareil de Duff est trop complexe pour ĂŞtre optimisĂ© de la sorte).

D’un autre cĂ´tĂ©, GCC n’arrive pas Ă  rĂ©duire la boucle simple plus qu’il ne rĂ©duit l’appareil de Duff. MĂŞme si Ă  -O1 la boucle simple est plus de cinq fois plus rapide, Ă  -O3 c’est l’appareil de Duff qui est 23% meilleur (ce qui est significatif)2.

Lisibilité et sémantique

Au premier coup d’Ĺ“il, l’appareil de Duff est une congruence très difficile Ă  apprĂ©hender. Cependant, c’est aujourd’hui un mĂ©canisme assez connu (surtout parmi les plus vieux dĂ©veloppeur·se·s C et C++). De plus, il a un dĂ©jĂ  un nom et qu’il possède une page Wikipedia qui explique son fonctionnement.

Tant que vous l’identifiez comme tel dans les commentaires de votre code, je pense qu’il n’est pas malsain de l’utiliser, mais si vos confrères et consĹ“urs ne le connaissent pas (au pire, vous pouvez mettre un lien vers sa page Wikipedia directement dans les commentaires !).

Chercher un cas plus spécifique

Principe

Le dĂ©roulage de boucle cherche spĂ©cifiquement Ă  rĂ©duire le nombre d’Ă©valuation de la structure de contrĂ´le de votre boucle. Du coup, j’ai construit un cas spĂ©cifique oĂą ce contrĂ´le est particulièrement lourd Ă  Ă©valuer, pour voir si ça permet Ă  l’appareil de Duff d’avoir un impact significatif.

Du coup, Ă  la place d’utiliser un entier comme index de boucle, j’ai utilisĂ© cette classe :

struct MyIndex
{
  int index;
   
  MyIndex(int base_index): index(base_index) {}
   
  MyIndex& operator++() 
  {  
    if (index%2 == 0)
      index+=3;
    else
      index-=1;
    return *this;
  }
 
  bool operator<(const MyIndex& rhs)
  {
    if (index%3 == 0)
      return index < rhs.index;
    else if (index%3 == 1)
      return index < rhs.index+2;
    else
      return index < rhs.index+6;
  }
};

Ă€ chaque fois qu’on incrĂ©mente ou compare MyIndex, on Ă©value un modulo (qui est une opĂ©ration arithmĂ©tique assez lente).

Et j’ai fait des benchmarks dessus.

Benchmarks

J’ai donc utilisĂ© le code suivant : Quick C++ Benchmarks – Duff’s device with strange index (quick-bench.com).

Cela m’a donnĂ© les rĂ©sultats suivants :

CompilateurOption d’optimisationBoucle simple
(cpu_time)
Appareil de Duff
(cpu_time)
Appareil de Duff /
Boucle simple
Clang 12.0-Og2.0694e+55.9710e+4– 71%
Clang 12.0-O11.8356e+55.8805e+4– 68%
Clang 12.0-O21.2318e-11.2582e-1+ 2.1%
Clang 12.0-O31.2955e-11.2553e-4– 3.1%
GCC 10.3-Og6.2676e+44.0014e+4– 36%
GCC 10.3-O17.0324e+46.0959e+4– 13%
GCC 10.3-O26.5143e+44.0898e-6– 100%
GCC 10.3-O34.1155e-64.0917e-6– 0.58%

Ici, on peut voir que l’appareil de Duff est meilleur que la boucle simple dans les plus basses couches d’optimisation, mais jamais significativement Ă  -O3. Cela signifie que le compilateur rĂ©ussit Ă  optimiser la boucle simple aussi bien que l’appareil de Duff l’est. C’est significativement diffĂ©rent des rĂ©sultats prĂ©cĂ©dents.

Pourquoi les résultats sont aussi inconsistants ?

Les benchmarks montrent des rĂ©sultats très peu consistants : par exemple, comment cela se fait-il que dans d’une computation() simple, avec GCC et -O1, la boucle simple est plus de cinq fois plus rapide que l’appareil de Duff, alors qu’avec -O3, c’est l’appareil de Duff qui est 23% meilleur ? Comment se fait-il que pour le mĂŞme code, Clang montre des rĂ©sultats totalement diffĂ©rents de GCC et montre que la boucle simple est trente mille fois plus rapide avec -O2 et -O3 ?

C’est parce que chaque compilateur a ses propres manières d’optimiser ces genres de boucles Ă  diffĂ©rents niveaux d’optimisation.

Si vous voulez regarder cela de plus près, vous pouvez comparer le code assembleur généré par chaque compilateur, comme dans cet exemple : Compiler Explorer (godbolt.org) où les versions Clang et GCC (à -O3) sont mises côte-à-côte.

J’aurais beaucoup aimĂ© dĂ©tailler tout cela ici, mais il faudrait largement plus d’un article dĂ©diĂ© pour tous les couvrir. Si vous, lecteur·rice, voulez prendre le temps de le faire et effectuer ces analyses vous-mĂŞme, je serai plus que contente de publier vos rĂ©sultats sur ce blog (vous pouvez me contacter ici).

Conclusion

Voici un résumé des résultats, qui indique pour chaque implémentation quel appareil est meilleur :

CompilateurOption d’optimisationcomputation() complexecomputation() trivialContrĂ´le de boucle lourd
Clang 12.0-OgAucunAucunAppareil de Duff
Clang 12.0-O1AucunAucunAppareil de Duff
Clang 12.0-O2AucunBoucle simpleAucun
Clang 12.0-O3AucunBoucle simpleAucun
GCC 10.3-OgAucunAucunAppareil de Duff
GCC 10.3-O1AucunBoucle simpleAppareil de Duff
GCC 10.3-O2AucunAucunAppareil de Duff
GCC 10.3-O3AucunAppareil de DuffAucun

Comment interpréter ces résultats ?

Premièrement, quand on a une computation complexe et une structure de contrĂ´le triviale, il n’y a pas de diffĂ©rence significative entre les deux.

Deuxièmement, quand la computation est triviale, c’est plus souvent la boucle simple qui l’emporte.

Troisièmement, conformĂ©ment Ă  nos Ă©ventuelles attentes, l’appareil de Duff est meilleur dans le cas d’une computation triviale mais d’un contrĂ´le de boucle complexe.

Et pour finir, les rĂ©sultats vont presque toujours dĂ©pendre de votre implĂ©mentation. En faisant mes recherches pour cet article, j’ai testĂ© diffĂ©rentes implĂ©mentations de l’appareil de Duff et je me suis souvent rendue compte que la plus petite modification dans le code pouvait renverser les rĂ©sultats des benchmarks.

Ce que je veux dire, c’est que l’appareil de Duff vaut, encore aujourd’hui, la peine d’ĂŞtre pris en considĂ©ration3, mais vous devrez faire vos propres benchmarks pour vous assurer que, dans votre cas spĂ©cifique, il est effectivement plus efficace.

Merci de votre attention et Ă  la semaine prochaine !

ticle original : Duff’s device in 2021 | Belay the C++ (belaycpp.com)
Traductrice : ChloĂ© Lourseyre

Addendum

Notes

  1. Le « cpu_time » indiquĂ© est une unitĂ© abstraite de mesure, affichĂ©e par Quick-bench. Elle n’a par elle-mĂŞme pas de sens, elle sert juste Ă  ĂŞtre comparĂ©e Ă  elle-mĂŞme dans les diffĂ©rentes implĂ©mentations benchmarkĂ©es.
  2. Ces rĂ©sultats dĂ©pendent aussi de l’implĂ©mentation de chaque compute_*(). Par exemple, si vous Ă©valuez (loop_size+3/4) Ă  chaque passage de boucle au lieu de la mettre dans une constante, les rĂ©sultats sous GCC vont ĂŞtre très diffĂ©rent et l’appareil de Duff ne sera plus significativement meilleur avec -O3.
  3. J’Ă©cris cette note juste pour vous rappeler une règle triviale : l’optimisation de temps d’exĂ©cution n’a de sens que si votre code est, en terme de temps d’exĂ©cution, particulièrement sensible. Si votre code n’est pas un goulot d’Ă©tranglement, vous ne devriez mĂŞme pas considĂ©rer utiliser l’appareil de Duff. Restez simples et n’oubliez pas la règles des 80-20.

Laisser un commentaire