Algorithme de Kahan: comment obtenir la différence exacte des produits

image

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


  1. 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.
  2. 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.
  3. 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 .
  4. 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.

Source: https://habr.com/ru/post/fr475370/


All Articles