Je suis récemment revenu à l'analyse des erreurs en virgule flottante pour affiner certains détails de la prochaine édition du livre sur le
rendu physique . Les nombres à virgule flottante sont un domaine de calcul intéressant, plein de surprises (bonnes et mauvaises), ainsi que des astuces délicates pour se débarrasser des mauvaises surprises.
Dans le processus, je suis tombé sur ce
post sur StackOverflow , à partir duquel j'ai appris un algorithme élégant pour le calcul exact
a foisbâc foisd .
Mais avant de poursuivre avec l'algorithme, vous devez comprendre ce qui est si rusé dans l'expression
a foisbâc foisd ? Prenez
a=33962.035 ,
b=â30438,8 ,
c=41563,4 et
d=â24871,969 . (Ce sont les vraies valeurs que j'ai obtenues lors du lancement de
pbrt .) Pour les valeurs flottantes 32 bits, nous obtenons:
a foisb=â1.03376365 fois109 et
c foisd=â1.03376352 fois109 . Nous effectuons la soustraction, et nous obtenons
â128 . Mais si vous effectuez les calculs avec une double prĂ©cision, et Ă la fin les convertissez en float, vous obtenez
â75.1656 . Qu'est-il arrivĂ©?
Le problĂšme est que la valeur de chaque Ćuvre peut aller bien au-delĂ du rĂ©sultat net
â1 fois109 , oĂč la distance entre les valeurs Ă virgule flottante reprĂ©sentables est trĂšs grande - 64. Autrement dit, lors de l'arrondi
a foisb et
c foisd individuellement, au flotteur représentable le plus proche, ils se transforment en nombres qui sont des multiples de 64. à leur tour, leur différence sera un multiple de 64, et il n'y aura aucun espoir qu'elle devienne
â75.1656 plus proche que
â64 . Dans notre cas, le rĂ©sultat Ă©tait encore plus loin en raison de l'arrondi des deux Ćuvres
â1 fois109 . Nous rencontrerons directement la bonne vieille rĂ©duction catastrophique
1 .
Voici une meilleure solution que
2 :
inline float DifferenceOfProducts(float a, float b, float c, float d) { float cd = c * d; float err = std::fma(-c, d, cd); float dop = std::fma(a, b, -cd); return dop + err; }
DifferenceOfProducts()
calcule
a foisbâc foisd d'une maniĂšre qui Ă©vite une contraction catastrophique. Cette technique a Ă©tĂ© dĂ©crite pour la premiĂšre fois par le lĂ©gendaire William Kahan dans l'article
sur le coût du calcul en virgule flottante sans arithmétique extra-précise . Il convient de noter que le travail de Kahan est intéressant à lire dans son ensemble, ils ont de nombreux commentaires sur l'état actuel du monde des virgules flottantes, ainsi que des considérations mathématiques et techniques. Voici une de ses conclusions:
Ceux d'entre nous qui ont luttĂ© avec les vicissitudes de l'arithmĂ©tique en virgule flottante et des "optimisations" mal pensĂ©es des compilateurs peuvent Ă juste titre ĂȘtre fiers de la victoire dans cette bataille. Mais si nous transmettons la poursuite de cette bataille aux gĂ©nĂ©rations futures, cela contredira toute l'essence de la civilisation. Notre expĂ©rience montre que les langages de programmation et les systĂšmes de dĂ©veloppement sont des sources de trop de chaos auxquels nous devons faire face. Trop d'erreurs peuvent ĂȘtre supprimĂ©es, ainsi que quelques «optimisations» attrayantes qui sont sans danger pour les entiers, mais s'avĂšrent parfois fatales pour les nombres Ă virgule flottante.
AprĂšs avoir rendu hommage Ă son esprit, revenons Ă
DifferenceOfProducts()
: la base de la maĂźtrise de cette fonction est l'utilisation d'instructions FMA
3 multipliées-ajoutées fusionnées. D'un point de vue mathématique,
FMA(a,b,c)
est
a foisb+c Par conséquent, au début, il semble que cette opération ne soit utile que comme microoptimisation: une instruction au lieu de deux. Cependant fma
Il a une propriété spéciale - il ne se termine qu'une seule fois.
Comme d'habitude
a foisb+c premier calculé
a foisb , puis cette valeur, qui dans le cas gĂ©nĂ©ral ne peut pas ĂȘtre reprĂ©sentĂ©e au format virgule flottante, est arrondie au flottant le plus proche. Ensuite, Ă cette valeur arrondie est ajoutĂ©e
c , et ce résultat est à nouveau arrondi au flottant le plus proche. FMA est implémenté de telle maniÚre que l'arrondi n'est effectué qu'à la fin - une valeur intermédiaire
a foisb conserve une précision suffisante, donc aprÚs l'avoir ajouté
c le résultat final sera le plus proche de la vraie valeur
a foisb+c valeur du flotteur.
AprÚs avoir traité avec FMA, nous reviendrons sur
DifferenceOfProducts()
. Encore une fois, je vais en montrer les deux premiĂšres lignes:
float cd = c * d; float err = std::fma(-c, d, cd);
Le premier calcule la valeur arrondie
c foisd et le second ... soustrait
c foisd de leur travail? Si vous ne savez pas comment fonctionne FMA, vous pourriez penser que l'
err
sera toujours nulle. Mais lorsque vous travaillez avec FMA, la deuxiÚme ligne extrait en fait la valeur de l'erreur d'arrondi dans la valeur calculée
c foisd et l'enregistre pour se
err
. AprĂšs cela, la sortie est trĂšs simple:
float dop = std::fma(a, b, -cd); return dop + err;
Le deuxiĂšme FMA calcule la diffĂ©rence entre les Ćuvres utilisant le FMA, en n'effectuant l'arrondi qu'Ă la toute fin. Par consĂ©quent, il rĂ©siste Ă une rĂ©duction catastrophique, mais il doit fonctionner avec une valeur arrondie.
c foisd . L'
return
"corrige" ce problÚme, en ajoutant l'erreur mise en évidence dans la deuxiÚme ligne. Dans un article de Jeannenrod et al.
il est démontré que le résultat est vrai jusqu'à 1,5 ulps (mesures de précision unitaire), ce qui est excellent: les opérations FMA et simples en virgule flottante sont précises jusqu'à 0,5 ulps, donc l'algorithme est presque parfait.
Nous utilisons un nouveau marteau
Lorsque vous commencez Ă chercher des moyens d'utiliser
DifferenceOfProducts()
, cela s'avÚre étonnamment utile. Calcul du discriminant de l'équation quadratique? Appelez
DifferenceOfProducts(b, b, 4 * a, c)
4 . Calcul du déterminant d'une matrice 2x2? L'algorithme résoudra ce problÚme. Dans la prochaine version de
pbrt, j'ai trouvĂ© environ 80 utilisations. De tous, la fonction du produit vectoriel est la plus apprĂ©ciĂ©e. Cela a toujours Ă©tĂ© une source de problĂšmes, Ă cause duquel vous avez dĂ» lever la main et utiliser le double dans la mise en Ćuvre pour Ă©viter une rĂ©duction catastrophique:
inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) { double v1x = v1.x, v1y = v1.y, v1z = v1.z; double v2x = v2.x, v2y = v2.y, v2z = v2.z; return Vector3f(v1y * v2z - v1z * v2y, v1z * v2x - v1x * v2z, v1x * v2y - v1y * v2x); }
Et maintenant, nous pouvons continuer Ă travailler avec float et utiliser
DifferenceOfProducts()
.
inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) { return Vector3f(DifferenceOfProducts(v1.y, v2.z, v1.z, v2.y), DifferenceOfProducts(v1.z, v2.x, v1.x, v2.z), DifferenceOfProducts(v1.x, v2.y, v1.y, v2.x)); }
Cet exemple astucieux du début de l'article fait en fait partie d'un travail vectoriel. à un certain stade, le code
pbrt doit calculer le produit vectoriel des vecteurs
(33962.035,41563.4,7706.415) et
(â24871.969,â30438.8,â5643.727) . Lorsque calculĂ© Ă l'aide de float, nous obtiendrions un vecteur
(1552,â1248,â128) . (RĂšgle gĂ©nĂ©rale: si dans les calculs en virgule flottante oĂč de grands nombres sont impliquĂ©s, vous n'obtenez pas des valeurs entiĂšres si grandes, alors c'est un signe presque sĂ»r qu'une rĂ©duction catastrophique s'est produite.)
Avec une double précision, le produit vectoriel est
(1556.0276,â1257.5151,â75.1656) . On voit qu'avec flotteur
x semble normal
y déjà assez mauvais aussi
z devient la catastrophe qui est devenue la motivation pour trouver une solution. Et quels résultats obtiendrons-nous avec
DifferenceOfProducts()
et les valeurs flottantes?
(1556.0276,â1257.5153,â75.1656) . Les valeurs
x et
z correspondent à une double précision, et
y lĂ©gĂšrement dĂ©calĂ© - d'oĂč le ulp supplĂ©mentaire est venu.
Et la vitesse?
DifferenceOfProducts()
effectue deux FMA, ainsi que la multiplication et l'addition. Un algorithme naĂŻf peut ĂȘtre implĂ©mentĂ© avec un FMA et une multiplication, ce qui, semble-t-il, devrait prendre la moitiĂ© du temps. Mais en pratique, il s'avĂšre qu'aprĂšs avoir obtenu les valeurs des registres, cela n'a pas d'importance: dans le cas-test synthĂ©tique tenu sur mon ordinateur portable,
DifferenceOfProducts()
que 1,09 fois plus cher que l'algorithme naïf. Le fonctionnement en double précision était 2,98 fois plus lent.
DÚs que vous apprenez la possibilité d'une réduction catastrophique, toutes sortes d'expressions d'apparence innocente dans le code commencent à sembler suspectes.
DifferenceOfProducts()
semble ĂȘtre un bon remĂšde pour la plupart d'entre eux. Il est facile Ă utiliser et nous n'avons aucune raison particuliĂšre de ne pas l'utiliser.
Remarques
- La rĂ©duction catastrophique n'est pas un problĂšme lors de la soustraction de quantitĂ©s avec des signes diffĂ©rents ou lors de l'ajout de valeurs avec le mĂȘme signe. Cependant, cela peut devenir un problĂšme lors de l'ajout de valeurs avec des signes diffĂ©rents. Autrement dit, les montants doivent ĂȘtre considĂ©rĂ©s avec le mĂȘme soupçon que les diffĂ©rences.
- Comme exercice pour le lecteur, je suggÚre d'écrire la fonction
SumOfProducts()
, qui offre une protection contre les contractions catastrophiques. Si vous voulez compliquer la tĂąche, expliquez pourquoi dans DifferenceOfProducts()
, dop + err == dop
, car les signes a*b
et c*d
sont diffĂ©rents. - L'instruction FMA est disponible sur le GPU depuis plus d'une dĂ©cennie et sur la plupart des CPU depuis au moins cinq ans. Il peut ĂȘtre nĂ©cessaire d'ajouter des drapeaux de compilation au CPU pour les gĂ©nĂ©rer directement lors de l'utilisation de
std::fma()
; dans gcc et clang, -march=native
travaux -march=native
. - Dans le format à virgule flottante IEEE, la multiplication par des puissances de deux est effectuée exactement, donc
4 * a
ne provoque aucune erreur d'arrondi, sauf en cas de débordement.