Rappelons tout d'abord quelques notations: en langage C, c ? a : b est une expression qui teste si une condition c est vraie, auquel cas l'expression s'évalue en la valeur de a, ou fausse, auquel cas elle s'évalue en la valeur de b. On peut ainsi écrire le calcul du minimum de x et de y de quatre façons « simples » :

  1. x < y ? x : y
  2. x <= y ? x : y
  3. x > y ? y : x
  4. x >= y ? y : x

Ces quatre expressions sont apparemment équivalentes. Pourtant, elles n'ont pas la même sémantique ! En effet, la norme IEEE-754 spécifie l'existence de certaines valeurs qui ont un comportement spécifique vis-à-vis de l'égalité == du langage C :

  • Les valeurs not a number (habituellement écrites NaN) qui signalent un résultat indéfini (0/0 ou √-1, par exemple). Pour toute valeur de x, et toute valeur NaN, x==NaN n'est jamais vraie. Ainsi, == n'est pas réflexive.
  • Les valeurs +0 et -0 sont distinctes. (+0) == (-0), et pourtant 1/(+0)=+∞; et 1/(-0)=-∞. Ainsi, ==, même restreinte aux valeurs non-NaN, n'est pas une égalité de Leibniz, puisque l'on peut exhiber x et y tels que x==y mais cependant il existe une expression e telle que e(x)==e(y) n'est pas vraie.

De même, l'inégalité large <= et l'inégalité stricte < (ne sont jamais vraies si l'un des opérandes est NaN).

Les quatre formules ci-dessus diffèrent donc :

x y x < y ? x : y x <= y ? x : y x > y ? y : x x >= y ? y : x
010000
0-0-000-0
nan111nannan

On peut penser que tout cela n'a aucune importance. Sur une architecture « traditionnelle » (i386), ces quatre expressions seront compilées à l'aide de tests et de sauts, sans grande différence. Cependant, les sauts conditionnels dépendant des données sont très mauvais pour les processeurs actuels, dotés de pipelines, de prédicteurs de branchement et d'exécution spéculative. Aussi, les générations suivantes de processeurs Intel ont amené des instructions de transfert conditionnel ; et, au final, les dernières générations, à jeu d'instruction SSE ou SSE2, possèdent des instructions minss et minsd calculant directement le minimum ou le maximum de deux nombres flottants simple et double précision.

Or, la sémantique de ces instructions est celle de la première expression, x < y ? x : y. Un compilateur rencontrant une des autres expressions sera tenté à des fins d'optimisation de compiler toutes les expressions comme si elles étaient équivalentes à la première. C'est ce que fait gcc si on lui passe l'option -ffast-math... et ce que fait icc par défaut. Par ailleurs, l'option -fp-model strict ne semble pas agir sur ce que fait icc sur les plate-formes AMD64 et EM64T : il est assez effrayant de constater qu'une option censée forcer une application stricte de la norme n'ait pas cet effet. Pour mémoire, voici ce que donne gcc -std=c99 -march=pentium3 -mfpmath=sse -O -ffast-math sur le test ci-dessus :

x y x < y ? x : y x <= y ? x : y x > y ? y : x x >= y ? y : x
010000
0-0-0-0-0-0
nan11111

Dans le cadre du projet Astrée, je me suis aperçu qu'une de nos bibliothèques, les octogones d'Antoine Miné, était très lente sur les processeurs Pentium 4 si on utilisait l'unité flottante x87, héritée du 80387. Peut-être est-ce dû à ce que cette unité est de plus en plus considérée comme obsolète, destinée à être remplacée par l'unité SSE3. En tout cas, nous avions sur ce processeur des performances très inférieures aux performances sur des processeurs Pentium 3 plus anciens. Une première amélioration a été d'utiliser l'unité SSE, avec -march=pentium4 -mfpmath=sse. Une deuxième a été de réécrire nos opérations de calcul de minimum et maximum en faveur du premier type d'expression ci-dessus, car permettant l'utilisation de l'instruction minsd.

La solution d'utiliser gcc -ffast-math est perfectible. Les optimisations que cette option permet ne respectent pas, en effet, la sémantique de ±∞, or nous utilisons la valeur +∞. Il nous est donc paru préférable d'effectuer à la main la transformation nécessaire.

Par ailleurs, la solution d'icc d'appliquer des optimisations ne respectant pas la sémantique des opérations flottantes par défaut, voire de façon non débrayable, nous paraît nuisible. Certes, cela fait mieux dans les benchmarks, mais cela peut poser des problèmes assez ennuyeux si l'on compile un logiciel sans se rendre compte que sa sémantique sera changée par défaut par le compilateur. J'ai alors étudié d'un peu plus près les performances comparées de gcc, gcc -ffast-math, et icc. Il m'est alors apparu que si icc, sur nos codes numériques, donne un code plus rapide que gcc, gcc -ffast-math donne au final un code légèrement plus rapide que icc !

J'ai ainsi découvert que, par exemple, icc, par défaut, pouvait réécrire les expressions flottantes par associativité, alors que bien évidemment les opérations sur les flottants ne sont pas associatives (voir aussi norme C99, §5.1.2.3, exemple 5). C'est notamment le cas quand icc tente de vectoriser une somme (pour calculer un produit scalaire, par exemple).

J'en conclus donc:

  • que deux formulations apparemment équivalentes de la même expression arithmétique peuvent donner des performances bien différentes ;
  • qu'il faut se méfier des compilateurs qui montrent des performances surprenantes : peut-être le font-ils en ignorant certaines règles.