Floating-point decimalen optellen voor het plezier en het gewin

Een beroemd voorbeeld is dat bij IEEE double-precision floats 0.1 + 0.2 niet 0.3 is, maar 0.30000000000000004.

Aan de andere kant gebruik ik constant een Python REPL om decimale getallen op bonnetjes op te tellen. Natuurlijk zijn de belangen bij mij lager; ik tel bedragen op in de range van $1 tot $100 en weet dat ik de microscopische fouten handmatig moet afronden voordat ik de som ergens kopieer. Maar eigenlijk gebeurt het heel vaak dat die fouten helemaal niet optreden.

Als we elk paar veelvouden van 0,01 optellen tot 1,00 en kijken of het geprinte resultaat te groot (blauw), te klein (rood) of correct is, ontstaat er een interessant patroon:

Afbeelding 1: Hoe floating-point invloed heeft op het optellen van twee veelvouden van 0,01, tot 1,00

Waar komt dit patroon vandaan?

Floats in het kort

Een floating-point getal met dubbele precisie ($x$) bestaat uit 1 tekenbit, 11 exponentbits en 52 fractiebits (in die volgorde), voor een totaal van 64 bits.

De tekenbit bepaalt het teken, $+$ of $-$. De exponentbits representeren een geheel getal $E$ tussen $-1022$ en $1023$, inclusief. De fractiebits representeren een niet-negatief geheel getal $F$ kleiner dan $2^{52}$, wat correspondeert met de significand $1 + F/2^{52} \in [1, 2)$; die altijd aanwezige $1$ wordt de "hidden bit" genoemd. De waarde van het floating-point getal is $x = \pm 2^E(1 + F/2^{52})$.

De effectieve waarde van de laatste fractiebit is dus $2^{E-52}$, een hoeveelheid die de ulp wordt genoemd, voor "unit in the last place", van $x$. De twee floating-point getallen die het dichtst bij $x$ liggen, verschillen er precies één ulp van, behalve in een randgeval aan één zijde wanneer $F = 0$ en $x$ precies een macht van twee is.

Voorbeeld: float(0.1) = 0.00011001100110011001100110011001100110011001100110011010₂ `ulp(float(0.1)) = 0.0000000000000000000000000000000