Controller ses opérations
flottantes en C++
|
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.
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.
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.
|