Controller ses opérations flottantes en C++


    Introduction

Tout développeur C++ connait le type flottant et est souvent au fait de sa représentation interne et de ce que cela implique sur la propagation d'erreur dans les calculs ou sur son domaine de précision, selon le nombre de bits d'encodage.

Cependant, peu de développeurs savent qu'il est tout à fait possible de controller et d'intéroger le FPU afin de modifier le comportement, la précision ou le control des erreurs lors des opérations flottantes.
    Rappel sur les flottants

Extrait de cours :

Les flottants sont des représentations en mémoire d'une partie des nombres rationnels; il est évidemment impossible de représenter des nombres réels quelconques, pour des raisons de cardinalité. Seuls les rationnels dont la forme irréductible est ( n / 2^q ), peuvent avoir une représentation exacte; les autres ont nécessairement une représentation approchée (par exemple, le nombre décimal 1/10 a comme représentation 0,000110011... en base 2, la partie 0011 étant répétée indéfiniment).

Cette représentation fait l'objet de la norme IEEE 754, adoptée par la plupart des fabricants d'ordinateurs. Cette norme distingue deux niveaux de précision : simple (sur 4 octets) et double (sur 8 octets).

Un flottant simple précision (sur 32 bits) se décompose en :

-> 1 but de signe (s), sur le bit 31
-> 8 bits d'exposant (e), sur les bits 30 à 23 inclu
-> 23 bits de mantisse (m), sur les bits 22 à 0 inclu

Extrait de cours :

La valeur normalisé d'un flottant est (-1)^s * m * 2^e, où :

s est le bit de signe
e est l'exposant = b30*2^7 + b29*2^6 + ... + b24*2^1 + b23 - 127
m est la mantisse = 1 + b22*2^-1 + b21*2^-2 + ... + b0*2^-23

L'exposant est biaisé et lui retirer 127 permet d'obtenir le plus petit exposant représentable. L'exposant le plus petit est de fait 2^-127 ( représenté par 0000 0000 ), et l'exposant le plus grand 2^128 ( représenté par 1111 1111 ). 2^0 est par exemple représenté par 0111 1111. Les représentations 0000 0000 et 1111 1111 ont des significations spéciales et servent à encoder 0, +/- INF, et NaN ( Not a Number ). De fait, les exposants extrêmes normalisés sont donc 2^-126 ( 0000 0001 ) et 2^127 ( 1111 1110 ).

Extrait de cours :

La valeur de la mantisse étant toujours 1 + ..., zéro n'est pas représentable. Par convention, le bit de signe suivi de 31 bits nuls représentent la valeur (-1)^s *0, et non (-1)^s x 2^-127. Il y aurait alors un "trou" entre 0 et le plus petit nombre normalisé, qui a comme bits d'exposant 0000 0001 et les bits de mantisse nuls. Pour combler ce trou, on convient que si les 8 bits d'exposant sont nuls, la valeur est un flottant dénormalisé, de valeur ( b22*2^-1 + b21*2^-2 + ... + b0*2^-23 ) * 2^-126.

Enfin, et c'est important de le rappeler :

Bien que portant les mêmes noms (addition, multiplication), les opérations flottantes ne sont pas ces opérations mathématiques, et n'ont pas les mêmes propriétés. Par exemple, l'addition n'est pas associative :

On peut vérifier que..
( 10000003.0 - 10000000.0 ) + 7.501 = 10.501
..alors que..
10000003.0 - ( 10000000.0 + 7.501 ) = 11.0

Découlant de ce principe, tester l'égalité ou la différence de flottants en utilisant les opérations == ou != est une pratique à proscrire. Il est plus juste de tester un écart relatif comme suit :

if ( fabs ( x - y ) < FLT_EPSILON * x ) { ... }

    Division par zéro, underflow, overflow et dénormalisation

La plupart des développeurs, un jour, se sont demandés comment gérer les divisions par zero. Les divisions par zero sur des entiers génèrent automatiquement une exception qu'il est possible d'attraper. Les division par zero sur des flottants genèrent elles-aussi une exception. Pourtant si vous faites l'essai de diviser un flottant implicitement par zero, aucune exception ne sera levée et le résultat sera indéfini ( 1.#INF000 ). Pourquoi ?

Il arrive fréquamment que des opérations flottantes sur des valeurs très grandes ou très petites engendrent un résultat dépassant les capacités de représentation des flottants ( appelé underflow et overflow ). Il arrive aussi que des opérations sur des flottants proches de zero engendre une conversion de l'exposant de l'un d'entre eux ( appelé la dénormalisation ). Comment prévenir ces cas ?

Toutes ces réponses résident dans le control du FPU. Il est possible de générer une exception lors d'une division par zero sur un flottant, comme il est possible de générer une exception lors d'un dépassement de capacité. Il est aussi possible de simplement interroger le FPU du résultat d'une opération flottante, sans demander d'engendrer d'exceptions.

    Status du FPU après opérations sur flottants

Pour connaître le status de la dernière opération sur un flottant, il faut utiliser la méthode _statusfp ( la version portable de _status87 ). Cette méthode est définie dans <float.h> et retourne un ensemble de flags binaires définissant l'état du FPU. Pour effacer le status, il faut utiliser la méthode _clearfp.

Exemple inspiré de celui de la MSDN :

double a = 1e-40, b;
float x, y;

clearfp();

printf ( "FPU status is %.4x", _statusfp() );

y = a; // opération entraine inexactitude et overflow
printf ( "FPU status is %.4x", _statusfp() );

b = y; // y est dénormalisé
printf ( "FPU status is %.4x", _statusfp() );

clearfp();

Au final, le FPU status est 80003, ce qui veut dire que l'ensemble des opérations flottantes ont engendrés inexactitude, overflow et dénormalisation.

    Lever une exception selon le status du FPU

Pour demander à engendrer des exceptions selon le changement de status du FPU, il faut utiliser la méthode _controlfp. Cette méthode permet de définir le masque des exceptions pour chaque interruption matériel du FPU. La documentation MSDN n'est pas très claire à ce sujet, mais il faut combiner en paramètre d'entrée de _controlfp, la valeur du masque retourné par un premier appel à _controlfp avec des paramètres à zero.

Pour activer l'exception lors d'une division par zero sur flottant, cela donne :

unsigned int ui;
ui = _controlfp ( 0, 0 );
ui = ui & ~(_EM_ZERODIVIDE);
_controlfp ( ui, _MCW_EM );

A noter, qu'il est possible de générer des exceptions pour d'autre interruptions, dont voici la liste :

-> _EM_INVALID : opération invalide sur flottant ( utilisation de -1.INF, 0.0f / 0.0f, etc... ).
-> _EM_DENORMAL : dénormalisation.
-> _EM_ZERODIVIDE : division par zéro.
-> _EM_OVERFLOW / _EM_UNDERFLOW : dépassement de capacité.
-> _EM_INEXACT : inéxactitude dans le résultat d'une opération.

Il existe des subtilités sur certains flags, notamment _EM_OVERFLOW &_EM_UNDERFLOW, et il est conseillé de lire la documentation de la MSDN à ce sujet.

Notez qu'il existe d'autre possibilités avec la méthode _controlfp, comme changer le mode d'arrondis dans les opérations flottantes ou la précision des flottants ( 24, 53 ou 64 bits ), ce qui est relativement périlleux à moins de savoir ce que l'on fait.

    Conclusion

Gràce aux fonctions présentées plus haut, vous devriez avoir un control maximum sur vos opérations sur flottants, et plus aucune opération flottante ne donnera un résultat incorrect de manière silencieuse.

 


Copyright 2006 Alexis PAUTROT