Java et le type double: quand ça ne passe pas à l'échelle !

D'où l'importance de savoir à l'avance les ordres de grandeur de votre programme

Billet le bonjour à vous,

J’aimerai vous raconter l’histoire d’un programme avec lequel je me suis pris la tête avant de me rendre compte que le problème était entre la chaise et le clavier, et aussi un peu dans la tasse de café ☕ .

Voici à quoi ressemble le morceau de code qui a fait souffrir le rongeur que je suis.

jshell> double v0=1073741824000000000000000000000000000000.0;
v0 ==> 1.073741824E39

jshell> double v1 = v0 - 1.0;
v1 ==> 1.073741824E39

jshell> System.out.println("difference = "+(v0 - v1));
difference = 0.0

😮

Je pense que vous aurez compris que je m’attendais à ce qu’à la ligne 8, la différence m’affiche la valeur 1.0 au lieu de 0.0. Que nenni ! J’ai été trompé !

L’explication

En Java lorsqu’on dépasse une certaine valeur (comme c’est mon cas) le type double n’est plus utilisable et pour ça, il faut utiliser le type BigDecimal.

Cependant, on ne peut pas prendre comme choix par défaut dans tous nos programmes, le type BigDecimal car les calculs avec ce dernier sont plus lent que ceux avec le type double.

Sauf que quand vous êtes comme moi et que : 1. vous commencez à écrire votre programme en testant avec des petits nombres, 2. vous embarquez votre code dans un fonction qui semble bien faire le job, 3. le résultat de la fonction dépend de l’entrée utilisateur

Le résultat devient imprévisible.

Que fait la police le compilo ?

Ce que je ne comprend pas, c’est qu’en 2021, le compilateur ne sait pas me prévenir d’un tel danger et que la JVM ne lève aucune exception sur ce genre de cas (même mon éditeur préféré ne m’a rien remonté).

La variable du mauvais type (n’y voyez aucune jeu de mot) continue d’accepter une valeur qu’elle ne peut supporter et le seul moyen de se rendre compte du problème c’est d’avoir eu la lucidité de tester avec la valeur qui donne le mauvais résultat (sans planter le programme).



Souvenez-vous donc qu’en Java, le type double (et int d’ailleurs) ne passe pas à l’échelle, mais est trop timide pour nous l’avouer.

Maintenant j’en suis à me demander si ma banque utilise le bon type de donnée quand elle calcule mes intérêts. 😱

Et vous, comment c’est géré dans votre langage préféré ?

30 commentaires

Salut,

Ton problème n’est pas lié à la valeur maximale du flottant, qui pour un double habituel est de l’ordre de 10308, donc bien au delà de 1039. Ce qu’il se passe, c’est un phénomène d’absorption, lié au nombre de chiffres significatifs qui peuvent être stockés dans un double. L’écart entre tes deux nombres est tellement important, que le plus petit ne peut même pas être soustrait de manière visible du plus grand.

Ce que je ne comprend pas, c’est qu’en 2021, le compilateur ne sait pas me prévenir d’un tel danger et que la JVM ne lève aucune exception sur ce genre de cas (même mon éditeur préféré ne m’a rien remonté).

La variable du mauvais type (n’y voyez aucune jeu de mot) continue d’accepter une valeur qu’elle ne peut supporter et le seul moyen de se rendre compte du problème c’est d’avoir eu la lucidité de tester avec la valeur qui donne le mauvais résultat (sans planter le programme).

Ça pourrait être intéressant d’avoir des avertissements sur ce genre de choses en effet. Mais ce n’est pas vraiment possible sans connaître les valeurs avant la compilation… Même pour les entiers d’ailleurs, si tu veux éviter les overflow.

Le vrai problème, c’est plutôt l’inculture sur ce qu’est un flottant et quand il ne faut pas les utiliser. Les flottants ont été inventés essentiellement pour faire des calculs physiques, pas exacts. À partir du moment où tu veux des calculs exacts, les double ne conviennent pas, à part si tu es sûr que c’est compatible (ce qui est contraignant et pas trivial à déterminer).

Dans ton cas, tu pourrais peut-être te contenter d’un format à virgule fixe avec assez de place (en gros, un entier un peu pimpé), ça pourrait être plus performant qu’un BigDecimal à l’usage.

+3 -0

Merci @Aabu pour la précision il s’agit en effet d’un problème de d’absorption.

Mais ce n’est pas vraiment possible sans connaître les valeurs avant la compilation… Même pour les entiers d’ailleurs, si tu veux éviter les overflow.

Aabu

Sur ce genre d’erreur je m’attends à minima a un levée d’exception par la JVM, donc au runtime.

Et suivant comment est faite ta fonction, tu peux aussi essayer d’ordonner différemment tes calculs pour éviter ces phénomènes d’absorption

entwanne

La fonction était un peu plus complexe que ce que j’ai montré dans le billet, c’est pour cela que le problème était plus complexe à identifier.

La norme IEEE-754 décrit tout plein de flags levés sur des comportements non-nominaux (qui ont un résultat par défaut défini malgré tout). Je connais pas le matériel, mais quand le matériel fait ça correctement (ce qui doit être le cas sur les implémentations connues), c’est rare que le langage permette facilement d’accéder à ces flags.

Si tu as accès dans le langage, tu peux regarder les flags après certaines opérations pour être au courant de ce qu’il s’est passé concrètement lors du calcul.

Par exemple dans notre cas, ça serait probablement l’info inexact qui devrait être levée :

Unless stated otherwise, if the rounded result of an operation is inexact — that is, it differs from what would have been computed were both exponent range and precision unbounded — then the inexact exception shall be signaled. The rounded or overflowed result shall be delivered to the destination.

IEEE-754
+0 -0

Par exemple dans notre cas, ça serait probablement l’info inexact qui devrait être levée :

Aabu

Je n’ai pas encore regardé, mais si la norme dis vrai, il faudrait que cette info soit remontée d’une manière ou d’une autre.

Je viens de faire le test en Python (2 et 3), et ben j’ai le même retour qu’avec mon programme Java. Et toujours pas de warning qui me dit que ce que j’essaye de faire est incertain.

Comme je disais, c’est rare que le langage permette d’accéder à ces flags facilement, alors le faire automatiquement, c’est une autre paire de manche. Surtout que ces comportements sont complètement normaux. C’est un peu l’analogue de faire une division euclidienne avec des entiers et s’étonner d’obtenir un nombre entier qui ne vaut pas A x B, mais un peu moins.

Le mot exception dans la norme n’a rien à voir avec les exceptions dans les langages, la norme est agnostique à ce sujet. Ça décrit juste un flag que tu devrais pouvoir lire sur une implémentation conforme d’une manière ou d’une autre.

Ça me donne envie de tester d’ailleurs si on peut choper ces flags d’une façon ou d’une autre.

Unless stated otherwise, if the rounded result of an operation is inexact — that is, it differs from what would have been computed were both exponent range and precision unbounded — then the inexact exception shall be signaled. The rounded or overflowed result shall be delivered to the destination.

C’est pas franchement clair, est-ce qu’il faudrait aussi lever ça si on calcule 1. / 10. parce que 0.1 n’est pas représentable ? Si oui, ça rend le warning assez inutilisable parce qu’il serait là partout, et sinon ça rend le warning peu significatif.

Je viens de faire le test en Python (2 et 3), et ben j’ai le même retour qu’avec mon programme Java. Et toujours pas de warning qui me dit que ce que j’essaye de faire est incertain.

Les calculs sur les flottants sont presque toujours incertains parce que tu travailles à précision finie. Il faudrait lever un warning quasiment tout le temps, ce qui rend la chose peu utile et plombe complètement les performances. Comme le dit @Aabu, le vrai problème est la méconnaissance des nombres flottants. Si tu as une précision finie dans une base donnée, tu ne travailles pas avec des réels mais avec un sous-ensemble de taille finie des nombres rationnels. L’avantage est que c’est rapide et plutôt facilement gérable mais ça vient avec plein de bizarreries et d’arrondis dans tous les sens.

+1 -0

Ce que je ne comprend pas, c’est qu’en 2021, le compilateur ne sait pas me prévenir d’un tel danger et que la JVM ne lève aucune exception sur ce genre de cas (même mon éditeur préféré ne m’a rien remonté).

La variable du mauvais type (n’y voyez aucune jeu de mot) continue d’accepter une valeur qu’elle ne peut supporter et le seul moyen de se rendre compte du problème c’est d’avoir eu la lucidité de tester avec la valeur qui donne le mauvais résultat (sans planter le programme).

J’ai envie de dire, tu n’aurais pas ce problème en Ada :P

Maintenant j’en suis à me demander si ma banque utilise le bon type de donnée quand elle calcule mes intérêts. 😱

La réponse et très certainement « Oui », ils utilisent le bon type. Je n’ai pas bossé dans une boite dans le domaine bancaire (en faite si, mais ce n’étais pas en Java), mais j’ai bossé dans une grosse boite où il avait des contraintes similaires (pricings, avec beaucoup beaucoup de clients, etc.) et ils utilisaient en effet le BigDecimal, ou BigQuelqueChose.

J’ai envie de dire, tu n’aurais pas ce problème en Ada :P

Hem… Ce programme affiche joyeusement 0, comme attendu en calcul flottant (il peut aussi refuser de compiler selon la machine utilisé d’ailleurs, comme il n’y a aucune garantie absolue sur la taille de Long_Float et donc sur le fait que la valeur de A va passer, pas le meilleur choix de Ada :-° ). Le fait que les calculs sont fondamentalement à précision finie est considéré comme connu du développeur, même dans les langages les plus prudents comme Ada ou Rust (ses f64 se comportent exactement pareil).

with Ada.Text_IO;

procedure Main is
    A : Long_Float := 1073741824000000000000000000000000000000.0;
    B : Long_Float := A - 1.0;
    C : Long_Float := A - B;
begin
    Ada.Text_IO.Put_Line("Out " & Long_Float'Image(C));
end Main;
+1 -0

J’ai envie de dire, tu n’aurais pas ce problème en Ada :P

Heziode

Sûr ? Pourquoi ça ?

ache

Je disais ça juste pour tacler, du troll gentil :P

Comme l’a souligné @adri1, le problème est relativement fondamental, et est plus lié à la représentation des nombres à virgules par les machines, que le langage utilisé.

Et on se retrouve après avec des bastons entre point fixe, point flottant, poing dans la gueule, etc. Mais bon, chaque type à son usage.

J’ai fait quelques tests pour montrer comment on peut récupérer les flags. On voit par exemple que le processeur nous informe que le calcul de firm1 est inexact. En pratique, ça sert rarement à quelque chose, mais on a comme ça accès à ce que raconte le processeur lorsqu’il fait les calculs.

#include <stdlib.h>
#include <stdio.h>
#include <fenv.h>

void test(double a, double b) {
        feclearexcept(FE_ALL_EXCEPT);

        double c = a + b;

        if (fetestexcept(FE_INEXACT))
            printf("%.17f + %.17f -> %.17f\nInexact\n", a, b, c);
        else
            printf("%.17f + %.17f -> %.17f\nExact\n", a, b, c);
}

int main (void) {
        // exact
        test(0.5, 2.1);
        // exact
        test(0.2, 0.3);
        // inexact
        test(0.2, 0.1);
        // inexact
        test(1.073741824e39, -1.0);

        return EXIT_SUCCESS;
}

La sortie du programme :

0.50000000000000000 + 2.10000000000000009 -> 2.60000000000000009
Exact
0.20000000000000001 + 0.29999999999999999 -> 0.50000000000000000
Exact
0.20000000000000001 + 0.10000000000000001 -> 0.30000000000000004
Inexact
1073741824000000021350953343814199148544.00000000000000000 + -1.00000000000000000 -> 1073741824000000021350953343814199148544.00000000000000000
Inexact

C’est pas franchement clair, est-ce qu’il faudrait aussi lever ça si on calcule 1. / 10. parce que 0.1 n’est pas représentable ? Si oui, ça rend le warning assez inutilisable parce qu’il serait là partout, et sinon ça rend le warning peu significatif.

Ça reste relativement peu utile en effet, à part pour des besoins spécialisés que je ne connais pas. Ce sont plutôt les calculs exacts qui sont remarquables avec les flottants !

Et donc pour répondre à la question d'@adri1 plus haut, le flag FE_INEXACT est bien levé pour le calcul de 1.0 / 10.0.

entwanne

J’ai directement vérifié également. ^^ Dès qu’il fait une opération qu’il ne peux pas représenté parfaitement, il déclenche ce flag.
C’est inutile, mais en même temps, c’est logique.

1 + 10E39 = 1E39 nous parait surprenant alors que 1 + 10E-39 = 1 nous parait tout à fait normal. Niveau opération c’est la même chose, juste la mantisse qui change.

D’ailleurs, pour compiler le code d'@Aabu, il faut liker la lib math (-lm pour gcc).

+0 -0

Pour moi l’article n’est pas très surprenant, c’est une propriété habituelle (connue) des nombres à virgules flottantes, qui sont bien spécifiés dans la norme IEEE-machin. Mais il reste très utile pour faire réaliser aux gens qu’il y a un vrai sujet et qu’il ne faut pas être top naïf sur les nombres à virgules; c’est important quand on est informaticien-ne d’être au courant des propriétés des types de données qu’on utilise. (Les flottants sont compliqués, mais il y a pire: le temps, les dates, les noms de personnes… Même la question de savoir comment mettre du texte en majuscule c’est l’horreur !)

Je suis donc tout à fait d’accord avec @Aabu quand il dit:

Le vrai problème, c’est plutôt l’inculture sur ce qu’est un flottant et quand il ne faut pas les utiliser. Les flottants ont été inventés essentiellement pour faire des calculs physiques, pas exacts. À partir du moment où tu veux des calculs exacts, les double ne conviennent pas, à part si tu es sûr que c’est compatible (ce qui est contraignant et pas trivial à déterminer).

Mais en même temps il y a une dimension qui manque à l’article et à la discussion : c’est quoi, au juste, le code de @firm1 qui fait des manipulations sur des nombres si grands que les double perdent en précision ? Avec les types simple-précision (float plutôt que double), on a des soucis sur des grandeurs sur lesquelles on peut assez naturellement tomber sur des programmes de la vie courante. Mais avec les doubles, c’est moins évident ! Personellement je n’ai jamais eu besoin d’écrire un programme qui a besoin d’être précis sur les calculs sur une grandeur d’ordre de 1.07E39.

Quel est le cas d’usage ici ? Tu peux nous en parler ? Une raison pour laquelle ces caractéristiques contre-intuitives des flottants sont si peu connues, c’est qu’on tombe rarement dessus, donc l’illusion de précision est maintenue la plupart du temps. Qu’est-ce qui t’a amené hors des clous ? Il y a une histoire intéressante derrière, qui manque à ce billet.

+0 -0

Boff, ce sont des cas que l’on rencontre quand on fait de la physique par exemple.

La question essentielle est :

Maintenant j’en suis à me demander si ma banque utilise le bon type de donnée quand elle calcule mes intérêts. 😱

Si ses intérêts posent des problèmes aux doubles, mais combien d’argent a-t-il investit ?!

+0 -0

Merci @Aabu pour le code d’exemple qui montre bien la nature inexacte de l’opération.

Ceci dit

Le vrai problème, c’est plutôt l’inculture sur ce qu’est un flottant et quand il ne faut pas les utiliser. Les flottants ont été inventés essentiellement pour faire des calculs physiques, pas exacts. À partir du moment où tu veux des calculs exacts, les double ne conviennent pas, à part si tu es sûr que c’est compatible (ce qui est contraignant et pas trivial à déterminer).

J’insiste bien sur le fait que ce que ce qui me dérange c’est que du point du vue du développeur, en 2021 il n y a rien pour l’avertir qu’il a essayé de faire quelque chose qui n’inexact.

Mais en même temps il y a une dimension qui manque à l’article et à la discussion : c’est quoi, au juste, le code de firm1 qui fait des manipulations sur des nombres si grands que les double perdent en précision ?

gasche

Dans mon cas d’utilisation, j’avais une opération distribuée sur plusieurs instances de calcul (Spark en l’occurrence) et chaque instance devait décrémenter la valeur initialement fournie (le fameux double) selon ce qu’elle avait effectuée comme traitement sur l’instance. Ça ne me semble pas très surréaliste comme scénario, même si j’imagine que ça n’arrive pas tous les quatre matin.

La question essentielle est :

Maintenant j’en suis à me demander si ma banque utilise le bon type de donnée quand elle calcule mes intérêts. 😱

Si ses intérêts posent des problèmes aux doubles, mais combien d’argent a-t-il investit ?!

ache

Le coup des intérêts c’était un blague hein :) , Si on reprend la formule de calcul d’intérêt, il faut avoir un sacré compte en banque (ce qui n’est pas mon cas, ou peut-être en dollars Zimbabwéen et encore) pour que ça commence à devenir imprécis.

@ache:

Boff, ce sont des cas que l’on rencontre quand on fait de la physique par exemple.

Peux-tu être plus précis, dans quels cas doit-on faire des calculs exacts sur des grandeurs de cet ordre ?

@firm1:

Dans mon cas d’utilisation, j’avais une opération distribuée sur plusieurs instances de calcul (Spark en l’occurrence) et chaque instance devait décrémenter la valeur initialement fournie (le fameux double) selon ce qu’elle avait effectuée comme traitement sur l’instance. Ça ne me semble pas très surréaliste comme scénario, même si j’imagine que ça n’arrive pas tous les quatre matin.

Est-ce que tu pourrais développer un peu plus ?

  • Pourquoi utiliser un double plutôt qu’un entier ?
  • Quel genre de travail fais-tu pour que le "compteur de travail" soit aussi gros ?

(Un rapide calcul sur 1e39 suggère que si cette valeur représentait un nombre de cycles de processeurs que les machines devaient dépenser ensemble de manière distribuée, un parc d’un million de processeurs à 3Ghz mettraient de l’ordre de 10_569_930_661_254_864 années à accomplir ce travail.)

+0 -0

J’insiste bien sur le fait que ce que ce qui me dérange c’est que du point du vue du développeur, en 2021 il n y a rien pour l’avertir qu’il a essayé de faire quelque chose qui n’inexact.

Je vais un peu faire mon vieux con, mais pour moi tu se prévenu par le seul fait que tu utilise des double et que donc rien ne te garantit l’exactitude des calculs ni ne te préviendra en cas d’inexactitude. C’est une propriété du type de données que tu dois connaître quant tu développes.

C’est le même principe qui fait que 7 / 2 = 3 si tu travailles avec des entiers, ou 10 / 3 = 3.333333333 pour n’importe quelle calculatrice de poche. Dans aucun de ces cas on te préviens de d’inexactitude, et pourtant elle est là et personne ne s’en plaint, parce que c’est le comportement attendu par les outils (c’est leurs limites et elles sont connues).

Je ne comprends pas pourquoi tu en es arrivé à utiliser un double pour contenir une valeur à décrémenter par contre.

@ache:

Boff, ce sont des cas que l’on rencontre quand on fait de la physique par exemple.

Peux-tu être plus précis, dans quels cas doit-on faire des calculs exacts sur des grandeurs de cet ordre ?

Jamais encore heureux ! Mais on doit se méfier de comment on fait les calculs tout simplement car on a un nombre de chiffre significatifs limité.

+0 -0

J’insiste bien sur le fait que ce que ce qui me dérange c’est que du point du vue du développeur, en 2021 il n y a rien pour l’avertir qu’il a essayé de faire quelque chose qui n’inexact.

Je vais un peu faire mon vieux con, mais pour moi tu se prévenu par le seul fait que tu utilise des double et que donc rien ne te garantit l’exactitude des calculs ni ne te préviendra en cas d’inexactitude. C’est une propriété du type de données que tu dois connaître quant tu développes.

C’est le même principe qui fait que 7 / 2 = 3 si tu travailles avec des entiers, ou 10 / 3 = 3.333333333 pour n’importe quelle calculatrice de poche. Dans aucun de ces cas on te préviens de d’inexactitude, et pourtant elle est là et personne ne s’en plaint, parce que c’est le comportement attendu par les outils (c’est leurs limites et elles sont connues).

Ok ok, je crois que c’est moi qui ait longtemps vécu dans l’ignorance.

Je ne comprends pas pourquoi tu en es arrivé à utiliser un double pour contenir une valeur à décrémenter par contre.

SpaceFox

En fait j’aurai pu utiliser des entiers à la place (c’est ce que j’ai fini par faire d’ailleurs), puisque finalement ce ne sont pas des valeurs décimales qui sont calculées. Mais étant donné que je m’appuyais sur une autre fonction dont la signature retourne un double je n’ai pas cherché plus que ça croyant naievement que ça ne poserait pas de problème, d’ou ce billet.

Quel genre de travail fais-tu pour que le "compteur de travail" soit aussi gros ?

gasche

Il s’agit d’une fonction qui parcours un arbre en largeur d’abord (parcours BFS) et ou chaque noeuds est traité par une instance de calcul. Chaque noeud reçoit la valeur totale du nombres de feuilles de l’arbre et la décrémente du nombre de feuille qu’il élimine rapidement par un algo.

Comme je le disais plus haut, ça se règle en utilisant des entiers (puisqu’on utilise un compteur), mais encore fallait t-il que je sache que le type double était aussi casse gueule.

Mais comment te retrouves-tu à manipuler un arbre aussi gros ? Tu fais une IA pour un jeu avec une explosion combinatoire du nombre de coups possibles, et tu utilises un algorithme classique pour réduire beaucoup l’espace à explorer ?

(Tu n’as pas envie de parler de ça, c’est un programme confidentiel ? Pas de soucis mais je suis un peu étonné de devoir écrire un troisième message pour re-re-dire que ce serait intéressant de savoir comment tu te retrouves à manipuler des nombres aussi gros.)

Edit: ça paraît un peu bizarre de faire un parcours BFS avec Spark, tu n’es pas tué par les coûts de synchronization ? Ou alors il y a beaucoup de calcul pour chaque noeud du graphe ? (Ça ne semble pas réaliste si le graphe est très gros.)

+0 -0

Ah je pensais l’avoir dit dans le billet, je ne sais pas si j’ai le droit d’en parler publiquement pour le moment. D’ou le fait que je ne présente que la partie technique et non fonctionnelle désolé.

EDIT

Edit: ça paraît un peu bizarre de faire un parcours BFS avec Spark, tu n’es pas tué par les coûts de synchronization ? Ou alors il y a beaucoup de calcul pour chaque noeud du graphe ? (Ça ne semble pas réaliste si le graphe est très gros.)

Tant que tu évite de faire des opérations terminales trop souvent ça ne pose pas de problème. Et à la base, le graphe n’est pas censé être aussi gros, le besoin a "évolué" entre temps.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte