3 points par GN⁺ 2025-09-13 | 1 commentaires | Partager sur WhatsApp
  • Cet article explique comment les valeurs en virgule flottante (float) sont stockées en mémoire et représentées
  • Il se concentre sur les formes hexadécimale et décimale des valeurs ainsi que sur leur méthode de conversion en valeur réelle
  • Il décrit la définition des zones signe (Sign), exposant (Exponent), significande (Significand) ainsi que le rôle de chacune
  • Il inclut des exemples montrant comment interpréter exactement quelles valeurs binaires et décimales représente une valeur float donnée
  • Il mentionne aussi le calcul de la différence (Delta) entre des valeurs représentables

Analyse de la structure de stockage des valeurs en virgule flottante

  • Il existe divers formats en virgule flottante comme "halfb float float double"
  • Chaque valeur peut être inspectée en mémoire comme Raw Hexadecimal Integer Value (valeur entière hexadécimale brute) ou Raw Decimal Integer Value (valeur entière décimale brute)
  • Les données hexadécimales sont reliées à l’écriture réelle en virgule flottante via la Hexadecimal Form ("%a")
  • La position de chaque valeur est indiquée dans la Significand–Exponent Range (position dans l’intervalle significande–exposant)

Méthode d’interprétation des valeurs binaires et décimales

  • Un nombre en virgule flottante peut être exprimé en Base-2 (expression évaluée en binaire) comme suit :
    • (−12)02×​102(100010012 − 011111112)​×​1.011111110010100000000002
      → il s’agit d’une évaluation numérique à partir d’une expression binaire
  • En Base-10 (expression évaluée en décimal), cela prend cette forme :
    • 1×​210×​1.4967041015625
      → exprimé comme le produit de 2 à la puissance 10 et d’une partie fractionnaire
  • La valeur décimale exacte obtenue lors de la conversion est également affichée :
    • présentée sous une forme comme 1.532625×​103

Calcul de la distance avec les valeurs voisines (Delta)

  • Le Delta (écart) entre les valeurs représentables a une signification importante
  • La distance jusqu’à la valeur représentable suivante ou précédente (Delta to Next/Previous Representable Value) est fournie séparément
    • Ex. : ±1.220703125×​10-4
  • Cet écart est lié au nombre de chiffres significatifs / à la précision des valeurs en virgule flottante

Résumé

  • Principes de la représentation en mémoire des nombres en virgule flottante et de leur conversion binaire et décimale
  • Explication de la structure sign, exponent, significand
  • Présentation conjointe de la plage de représentation et des écarts entre valeurs adjacentes

1 commentaires

 
GN⁺ 2025-09-13
Avis sur Hacker News
  • À propos de ce sujet, cette explication est la meilleure : https://fabiensanglard.net/floating_point_visually_explained/ Je suis tombé sur ce billet à mes débuts sur Hacker News, et c’est le genre de contenu qui m’a motivé à rester sur la plateforme : https://news.ycombinator.com/item?id=29368529

    • Je suis peut-être un peu trop orienté maths, mais je n’ai pas trouvé cette explication si simple que ça Si vous voulez une explication vraiment simple du flottant : il fournit à peu près le même nombre de bits de précision, quelle que soit l’échelle Autrement dit, qu’il s’agisse d’un nombre bien plus petit que 1, proche de 1, ou très grand, on peut s’attendre à peu près au même niveau de précision sur les bits de tête C’est la propriété essentielle, mais ce n’est pas facile à intérioriser

    • Ça s’inscrit très bien dans le contexte du billet récent écrit par l’équipe de recherche TM https://news.ycombinator.com/item?id=45200925

    • Je n’avais jamais vu ça aussi bien expliqué, donc merci pour le partage

  • L’un des problèmes sur lesquels je me suis longtemps creusé la tête, c’est « comment représenter une valeur float comme la chaîne décimale la plus courte possible tout en restant non ambiguë » Par exemple, avec un float en simple précision, il faut jusqu’à 9 chiffres de précision décimale pour identifier la valeur de manière unique Il faut donc utiliser un motif printf comme %.9g Mais dans ce cas, 0.1 s’affiche sous une forme disgracieuse comme 0.100000001 On choisit donc souvent d’arrondir à 6 chiffres, et avec %.6g, une valeur décimale saisie avec jusqu’à 6 chiffres peut être réaffichée identiquement à la valeur stockée En revanche, pour les valeurs issues d’un calcul, le round-trip n’est plus sûr C’est particulièrement important quand il faut comparer précisément des valeurs float (par exemple pour vérifier si des données ont changé) L’idée que j’avais eue était d’essayer d’abord avec 6 chiffres, puis, si la valeur obtenue après parsing redonne la même valeur binaire, de garder cette forme ; sinon de recommencer avec 7, 8 puis 9 chiffres pour trouver la représentation décimale la plus courte Mon algorithme ressemblait à ceci

    int out_length;
    char buffer[32];
    for (int prec = 6; prec<=9; prec++) {
      out_length = sprintf(buffer, "%.*g", prec, floatValue);
      if (prec == 9) {
        break;
      }
      float checked_number;
      sscanf(buffer, "%g", &checked_number);
      if (checked_number == floatValue) {
        break;
      }
    }
    

    Je me demande s’il existe une méthode plus efficace pour trouver la représentation la plus courte sans boucler sur printf/scanf

    • Ce problème est réellement important On peut le voir comme le problème consistant à produire une chaîne « normalisée » pour un float donné C’est pour ça qu’il existe divers algorithmes efficaces comme Dragon4, Grisu3, Ryu ou Dragonbox La bibliothèque double-conversion de Google implémente aussi les deux premiers

    • Il existe de meilleures méthodes que de faire une boucle printf/scanf Ça peut même se faire avec printf("%f", ...) uniquement L’algorithme réel de conversion float-vers-chaîne est assez complexe Un bon algorithme récent est https://github.com/ulfjack/ryu Je crois qu’une méthode encore plus efficace est sortie récemment, mais je ne me souviens plus de son nom

    • Il ne faut pas trop se soucier des avis négatifs ; même si ce n’est pas la meilleure méthode, en l’absence d’erreur, elle fonctionne généralement assez bien J’ai eu une expérience similaire : un jour, je voulais trouver un vecteur qui redeviendrait identique après une rotation d’Euler (5°, 5°, 0), alors j’ai déplacé aléatoirement un vecteur tout en regardant s’il se rapprochait du vecteur de référence J’ai fait tourner la boucle des millions de fois et j’ai obtenu un résultat en quelques secondes en Python Ce serait inefficace au niveau d’une bibliothèque, mais pour mon usage c’était tout à fait satisfaisant

    • Vous pouvez regarder std::numeric_limits<float>::max_digits10 https://en.cppreference.com/w/cpp/types/numeric_limits/max_digits10.html

    • Ça n’a pas de sens, et il ne faut surtout jamais utiliser sscanf() Si on convertit en entier non signé pour sérialiser/restaurer, c’est réversible sans perte d’information

      double f = 0.0/0.0; // peut nécessiter un drapeau soft error sur certains compilateurs
      double g;
      char s[9];
      
      assert(sizeof double == sizeof uint64_t);
      
      snprintf(s, 9, "%0" PRIu64, *(uint64_t *)(&f));
      
      snscanf(s, 9, "%0" SCNu64, (uint64_t *)(&g));
      

      Si vous avez besoin d’une représentation plus courte, utilisez une heuristique qui permette une restauration exacte, du moment que la précision d’origine est garantie (par exemple l’idempotence)

  • Mon astuce FP préférée, c’est que les comparaisons de float peuvent presque s’utiliser comme des comparaisons d’entiers Pour savoir si a > b, il suffit d’interpréter a et b comme des entiers signés puis de les comparer Cette approche fonctionne (presque) bien Autrement dit, la valeur float immédiatement supérieure s’obtient en convertissant son motif de bits en entier puis en lui ajoutant 1 Par exemple, si l’on part de 0.0 en float et qu’on ajoute 1 via une addition entière, on obtient précisément la valeur float suivante (un denormal, la plus petite valeur non nulle) C’est sur ce principe que nextafter est implémenté Savoir que l’ordre des float correspond à l’ordre de comparaison des entiers le rend beaucoup plus intuitif Bien sûr, il y a des exceptions : NaN, l’infini, le zéro négatif, etc. Il y a là quelques usages utiles, mais pas pour tout

    • Ce n’est pas exactement vrai C’est correct pour les positifs ou pour les comparaisons positif-négatif, mais pas entre nombres négatifs Les flottants standard (float) utilisent une représentation sign-magnitude, alors que les entiers signés modernes sont en complément à deux Pour les négatifs, l’ordre de comparaison des grandeurs s’inverse entre les deux Si on incrémente un float comme un int, on se déplace en général vers une valeur de plus grande « magnitude » dans le même signe Donc les positifs montent, tandis que les négatifs descendent vers des valeurs plus négatives Avec les entiers, on monte toujours, sauf en cas de dépassement Plus exactement, on peut dire que cela correspond à une comparaison d’entiers en sign-magnitude Bien sûr, les réserves mentionnées restent valables

    • À titre de référence, l’algorithme de comparaison d’ordre total utilisé dans la bibliothèque standard de Rust pour les flottants, où même NaN est ordonnable, est le suivant (recommandation IEEE 751)

      let mut left = self.to_bits() as i32;
      let mut right = other.to_bits() as i32;
      
      // Pour les valeurs négatives, on inverse tous les bits sauf le signe,
      // ce qui produit un ordre similaire à celui des entiers en complément à deux
      
      left ^= (((left >> 31) as u32) >> 1) as i32;
      right ^= (((right >> 31) as u32) >> 1) as i32;
      
      left.cmp(&right)
      

      Voir l’algorithme complet

  • J’ai découvert ce sujet via un cas présenté dans mon cours OMSCS de game AI, qui traitait des précautions à prendre lorsqu’on représente la position d’objets de jeu en flottants Plus on s’éloigne de l’origine ou du point de référence, plus les float doivent stocker de grandes valeurs, et plus on perd en précision, ce qui devient risqué

    • Il est intéressant que ce phénomène se soit cristallisé dans le mythe des Far Lands de Minecraft Plus on s’éloigne de l’origine du monde, plus la génération du terrain et la physique commencent à se comporter bizarrement, jusqu’à se casser complètement bien plus loin Il y a presque quelque chose d’occulte là-dedans, comme si les lois de la réalité se délitaient peu à peu Et tout cela vient simplement des limites de précision des float

    • Quand on additionne beaucoup de nombres entre 0 et 1 en float, la méthode consistant à les additionner simplement l’un après l’autre est bien moins précise qu’une méthode de sommation par paires, où on additionne d’abord deux à deux avant de recombiner C’est un bon exemple de l’impact considérable des erreurs d’accumulation en flottants Il y a eu de vrais cas où ce type d’erreur a été négligé et a causé des problèmes Donald Knuth explique dans « The Art of Computer Programming » certaines vérités fondamentales des flottants, comme a + (b + c) ≠ (a + b) + c Il y a aussi eu des incidents concrets dans le monde réel : le système de missiles Patriot accumulait le temps en flottants, ce qui ajoutait progressivement de l’erreur jusqu’à faire complètement manquer la cible et nécessiter un redémarrage Il fallait un redémarrage toutes les 24 heures, puis le logiciel du système a fini par être corrigé Il est aussi arrivé que de grandes structures s’effondrent à cause d’erreurs de flottants (par exemple lorsqu’une épaisseur était calculée trop faible)

    • Il faut d’abord définir les conditions aux limites afin d’établir le niveau de précision nécessaire On peut alors calculer à l’avance les distances minimales et maximales Si le monde devient trop grand, il faut le découper en secteurs ou gérer séparément des coordonnées globales et locales (comme dans No Man's Sky) Un jeu reste avant tout une machinerie de théâtre La double précision suffit dans la plupart des cas L’important est de se souvenir qu’il ne faut pas additionner de petites et de grandes valeurs ensemble

    • Kerbal Space Program s’appuie sur une ingénierie particulièrement astucieuse pour représenter un système solaire entier avec seulement des float 32 bits Il existe beaucoup d’articles et de vidéos à ce sujet, et je recommande vraiment de s’y intéresser

  • Cette visualisation est amusante, et je trouve intéressant qu’elle ressemble visuellement au calculateur de plages CIDR que j’avais créé il y a longtemps pour aider à comprendre les plages réseau Ce genre de visualisations est extrêmement utile

  • À l’époque, pour explorer la représentation des float, j’utilisais https://www.h-schmidt.net/FloatConverter/IEEE754.html L’avantage de ce site est qu’il montre aussi l’erreur de conversion, mais il ne prend pas en charge la double précision

    • J’ai déjà parcouru les commentaires pour voir si quelqu’un l’avait mentionné, et c’est vraiment une très bonne page web En revanche, le site présenté par l’OP explique de façon très intuitive, au moyen d’un graphique, la structure de partition de l’espace numérique L’axe vertical est en échelle logarithmique, et l’axe horizontal est linéaire sur chaque ligne mais normalisé par rapport aux intervalles logarithmiques Pour quelqu’un qui manipule déjà les flottants avec aisance, cela peut sembler évident, mais quand on débute, un peu d’explication supplémentaire aide beaucoup
  • Cela n’a pas encore été partagé dans ces commentaires, mais mon site préféré à propos des float est https://0.30000000000000004.com/

  • Pour les float 32 bits, « l’entier le plus intéressant » est sans doute 16777217 (et 9007199254740992 pour le 64 bits) C’est amusant à connaître comme edge case pour les tests

    • En float 64 bits, 9007199254740991 correspond à Number.MAX_SAFE_INTEGER en JavaScript Cette valeur est impaire, et la suivante, 9007199254740992, reste elle aussi sûre en elle-même, mais une valeur manifestement non sûre comme 9007199254740993 sera arrondie et ne pourra plus être distinguée

    • En float 64 bits, c’est exactement ±9,007,199,254,740,993.0 :-) À noter que ce type de valeur désigne le premier entier situé juste au-delà de la plus grande limite d’entiers que le flottant peut représenter « exactement » Par exemple, en float 32 bits, la valeur représentable qui suit ±16,777,216.0 est ±16,777,218.0 ±16,777,217.0 n’est pas représentable et sera généralement arrondie, souvent vers zéro, ou autrement selon le contexte Ces limites de précision et ces problèmes d’arrondi sont souvent sous-estimés

  • Je suis content que IEEE754 existe, mais IEEE754 n’est pas parfait, et je pense que des formats comme posit sont meilleurs (en supposant l’absence de support matériel) Les rationnels big-num sont encore supérieurs aux deux, mais aussi les plus lents

    • IEEE754 est un compromis qui cherche à couvrir de nombreux besoins Certaines approches alternatives sont meilleures dans des domaines précis, mais moins bonnes ailleurs
  • Ce serait vraiment génial d’ajouter la prise en charge des différents formats fp8 récemment introduits sur les GPU