Vergleich von Gleitkommazahlen – knifflig, aber machbar!

| Autor / Redakteur: Matt Kline * / Sebastian Gerstl

Layout eines 64-Bit IEEE 754 Floats.
Layout eines 64-Bit IEEE 754 Floats. (Bild: IEEE 754 Double Floating Point Format / Codekaizen / CC BY-SA 4.0)

Gleitkomma-Mathematik ist mit recht diffizilen und subtilen Problemen belastet. Der Vergleich von Werten macht da keine Ausnahme. In diesem Artikel diskutieren wir häufige Fallstricke, untersuchen mögliche Lösungen und versuchen, „Boost“-Probleme zu überlisten.

Wenn Sie eine nicht ganzzahlige Zahl in einer der etablierten Programmiersprachen darstellen wollen, werden Sie letzten Endes wahrscheinlich Gleitkomma-Zahlen nach IEEE 754 verwenden. Nach ihrer Normung 1984 wurden sie einfach allgegenwärtig. Fast alle modernen CPUs – und viele Mikroprozessoren – haben für sie eine spezielle Hardware, die sogenannten Floating-Point Units (FPUs, Gleitkomma-Einheiten).

Jede Variable des Typs float besteht aus einem Vorzeichenbit, einigen Bits, die den Exponenten repräsentieren und Bits, die einen Bruchteil, auch Mantisse genannt, darstellen. In den meisten Fällen kann der Wert einer Gleitkomma-Zahl durch diese Gleichung dargestellt werden.

Formel 1: Wert einer Gleitkomma-Zahl.
Formel 1: Wert einer Gleitkomma-Zahl. (Bild: Matt Kline / Bitbashing.io)

Hierbei stellt s unser Vorzeichenbit dar, m einen Bruchteil, der durch die Bits der Mantisse repräsentiert wird, e eine vorzeichenlose Ganzzahl, die durch die Bits des Exponenten dargestellt wird und c ist der halbe Maximalwert von e, das heißt 127 für eine 32-Bit Gleitkomma-Zahl und 1023 für eine 64-Bit Gleitkomma-Zahl.

Formel 2: Abwandlung der Gleichung, sobald alle Bits des Exponenten gleich Null betragen.
Formel 2: Abwandlung der Gleichung, sobald alle Bits des Exponenten gleich Null betragen. (Bild: Matt Kline / bitbashing.io)

Es gibt aber auch einige Spezialfälle. Sind zum Beispiel alle Bits des Exponenten Null, ändert sich die Formel entsprechend. Bemerkenswert ist der Entfall der „1“ vor der Mantisse. Dies erlaubt es uns, kleine Werte nahe Null zu speichern, sogenannte normalisierte oder denormalisierte Werte. Und wenn alle Bits im Exponenten Einsen sind, repräsentieren bestimme Werte der Mantisse +∞, - ∞ und keine Zahl (NaN / Not a Number), das Ergebnis einer undefinierten oder nicht darstellbaren Rechenoperation wie die Division durch Null.

Hier sind zwei Beobachtungen festzuhalten, die sich in Kürze als nützlich herausstellen.

1. Variablen vom Typ float können keine beliebigen realen Zahlen speichern, ganz zu schweigen von beliebigen rationalen Zahlen. Sie können ausschließlich Zahlen speichern, die durch die oben gezeigten Gleichungen dargestellt werden können. Wenn ich zum Beispiel die folgende Variable deklariere:

float f = 0.1f;

wird f als 0.100000001490116119384765625 dargestellt, was dem an 0,1 nächstliegenden 32-Bit Gleitkomma-Wert entspricht.

2. Da die Gleichungen exponentiell sind, nimmt der Abstand auf der Zahlengerade zwischen benachbarten Werten zu – und zwar exponentiell, je weiter man sich von der Null entfernt. Der Abstand zwischen 1,0 und dem nächsten möglichen Wert ist ungefähr 1,19 x 10-7, aber der Abstand zwischen zwei benachbarten Gleitkomma-Zahlen in der Nähe von 6,022 x 1023 ist ungefähr 3,6 x 1016. Das wird sich als eine unserer größten Herausforderung herausstellen: Wenn wir Gleitkomma-Zahlen vergleichen wollen, dann wollen wir Eingaben in der Nähe von Null genauso handhaben, wie Zahlen in der Nähe der Avogadro-Konstante.

Was ist Gleichheit?

Da das Ergebnis jeder Gleitkomma-Operation auf den nächstmöglichen Wert gerundet werden muss, verhält sich die Mathematik nicht so wie bei realen Zahlen. Abhängig von Ihrer Hardware, dem Compiler und den Compiler-Einstellungen, kann die Operation 0,1 x 10 ein anderes Ergebnis produzieren als ∑(n=1 bis 10) 0,1 (Auf meiner Maschine ergibt das Erstere 1,0 und das Letztere 1,000000119209290.).

Immer wenn wir berechnete Werte miteinander vergleichen, sollten wir einen gewissen Spielraum berücksichtigen. Ein Vergleich der genauen Werte mit == bringt es nicht. Stattdessen sollten wir zwei unterschiedliche Zahlen a und b als gleich ansehen, wenn für ein ausreichend kleines ε gilt: │a – b│ ≤ ε. Doch wie es der Zufall will, enthält die C-Standardbibliothek eine Definition FLT_EPSILON. Probieren wir es aus:

bool almostEqual (float a, float b)
{
    return fabs(a - b) <= FLT_EPSILON;
}

Eigentlich würden wir jetzt darauf hoffen, fertig zu sein, aber damit würden wir falsch liegen. Ein Blick in den Standard der Sprache zeigt, dass FLT_EPSILON gleich dem Abstand zwischen 1,0 und dem nächstliegenden Wert ist.

Aber, wie wir vorher festgestellt haben, sind Gleitkomma-Zahlen nicht äquidistant! Für Werte kleiner 1, wird FLT_EPSILON schnell zu groß, um nützlich zu sein. Für Werte größer 2, ist FLT_EPSILON kleiner als der Abstand zweier benachbarter Werte, so dass fabs(a - b) <= FLT_EPSILON immer false ergibt.

Was passiert, wenn wir zur Lösung dieser Probleme ε proportional zu unseren Eingangswerten skalieren?

Das funktioniert besser als unsere ursprüngliche Version, aber es ist nicht sofort offensichtlich, welche Werte von maxRelativeDiff wir für verschiedene Fälle verwenden wollen. Die Tatsache, dass wir maxRelativeDiff mit beliebigen Eingangswerten skalieren, kann bedeuten, dass wir demselben Rundungsmechanismus zum Opfer fallen, über den wir uns anfangs Sorgen gemacht hatten.

bool relativelyEqual(float a, float b,
    float maxRelativeDiff = FLT_EPSILON)
{
    const float difference = fabs(a - b);

    // Auf den größten Wert skalieren
    a = fabs(a);
    b = fabs(b);
    const float scaledEpsilon =
        maxRelativeDiff * max(a, b);

   return difference <= scaledEpsilon;

Was ist mit Boost?

Boost, die populäre Sammlung von C++ Bibliotheken, stellt Funktionen für ähnliche Zwecke zur Verfügung (Siehe „Floating Point Comparison“ im Abschnitt über die Gleitkomma-Hilfsprogramme im mathematischen Toolkit von Boost.). Entfernt man die Templates und die Behandlung von Randfällen wie ±∞ und NaNs, sehen sie ungefähr so aus:

float relative_difference(float a, float b)
{
     return fabs((a - b) / min(a, b));
}

float epsilon_difference(float a, float b)
{
    return relative_difference(a, b) /
        FLT_EPSILON;
}

Unglücklicherweise scheinen diese Funktionen unser Problem nicht zu lösen. Da die Division in relative_difference oftmals sehr kleine Ergebnisse liefert – der Wert für relative_difference beispielsweise zwischen 42 und dem nächstmöglichen Gleitkomma-Wert ist ungefähr 9,08 x 10-8 – stellt sich die Frage, was wohl ein guter Schwellenwert wäre. Durch die Division mit FLT_EPSILON versucht epsilon_difference einen einfacheren Wert zur Verfügung zu stellen. Aber wir haben die Gefahren von FLT_EPSILON gerade gesehen! Dieses Schema wird umso fragwürdiger, je weiter sich die Eingangswerte von Eins entfernen.

Was ist mit ULPs?

Es wäre schön, wenn Vergleiche mit etwas konkreterem als willkürlich gewählten Schwellenwerten durchgeführt werden könnten. Idealerweise würden wir gerne die Anzahl möglicher Gleitkomma-Werte – manchmal als „units of least precision (ULPs)“ bezeichnet – zwischen den Eingabewerten wissen.

Wenn ich irgendeinen Wert a habe und ein anderer Wert b ist nur zwei oder drei ULPs entfernt, dann können wir beide wahrscheinlich als gleich betrachten, in der Annahme, dass es sich um Rundungsfehler handelt. Und wichtiger: Dies gilt unabhängig vom Abstand zwischen a und b auf der Zahlengerade.

Boost stellt eine Funktion mit dem Namen float_distance zur Verfügung, um den Abstand zwischen Werten in ULPs zu bekommen, aber sie ist ungefähr eine Größenordnung langsamer als die Ansätze, die wir bisher diskutiert haben. Mit ein wenig Bitmanipulation können wir das besser machen.

Betrachten wir ein beliebige Gleitkomma-Zahl x, bei der jedes Bit in der Mantisse Eins ist. x + 1ULP muss dann den nächstgrößeren Exponenten verwenden und alle Bits in der Mantisse müssen dann Null sein. Schauen wir uns als Beispiel 1,99999988 und 2 an:

  Value     Bits     Exponent     Mantissa bits  
  1.99999988     0x3FFFFFFF     127     0x7FFFFF  
  2.0     0x40000000     128     0x000000  

Die Eigenschaft gilt für denormalisierte Zahlen, obwohl sie eine unterschiedliche Wertgleichung besitzen. Schauen wir uns die größte denormalisierte und die kleinste normalisierte Zahl an:

  Value     Bits     Exponent     Mantissa bits  
  1.1754942×10e-38     0x007FFFFF     -126     0x7FFFFF  
  1.17549435×10e-38​​       0x00800000     -126     0x000000  

Beachten Sie interessante logische Folge: Benachbarte Gleitkomma-Zahlen (mit dem gleichen Vorzeichen) besitzen benachbarte ganzzahlige Werte, wenn man sie als solche neu interpretiert. Diese Neuinterpretation wird manchmal als „type punning“ (Umgehung der Datentypen) bezeichnet und wir können es benutzen, um den Abstand zwischen Werten in ULPs zu berechnen.

Was ist mit ULPs?

Traditionellerweise benutzte man bisher in C und C++ den union-Trick:

union FloatPun {
    float f;
    int32_t i;
};

FloatPun fp;
fp.f = 25.624f;
// Lesen desselben Wertes als Integer.
printf("%x", fp.i);

Dies funktioniert in C immer noch, kann in C++ aber mit den strikten Aliasing-Regeln kollidieren (Siehe z.B. http://stackoverflow.com/q/11639947 und http://stackoverflow.com/q/17789928). Eine bessere Methode ist es, memcpy zu verwenden. Geht man vom normalen Gebrauch dieser Funktion aus, könnte man annehmen, dass dies weniger effizient ist, aber sehen wir uns den Code an:

int32_t floatToInt(float f)
{
    int32_t r;
    memcpy(&r, &f, sizeof(float));
    return r;
}

Dieses Beispiel compiliert zu einer einzigen Instruktion, die den Wert von einem Gleitzahl-Register in ein Ganzzahl-Register kopiert. Das ist genau das, was wir wollen.

Nachdem wir dieses Problem gelöst haben, wird die Berechnung der ULPs zwischen Werten ziemlich unkompliziert. Wir haben nun einen recht portierbaren Code gefunden; er setzt nur voraus, dass die Plattform 32-Bit Integer unterstützt und dass die Gleitkomma-Zahlen gemäß IEEE 754 gespeichert werden. (Anmerkung: Der Typ float ist gemäß dem C++ Standard implementationsabhängig und beachtet nicht notwendigerweise IEEE 754. Siehe auch http://stackoverflow.com/a/24157568). Dies ist eventuell der Grund für Boost’s derzeitige Implementation von float_distance.)

int32_t ulpsDistance(const float a, const float b)
{
    // Spar die Arbeit, falls die Gleitkomma-
    // Zahlen gleich sind.
    // Behandelt auch +0 == -0.

    if (a == b) return 0;

    const auto max =
        std::numeric_limits< int32_t >::max();

    // Maximaler Abstand für NaN.
    if (isnan(a) || isnan(b)) return max;

    // Ist eine Zahl unendlich und beide sind
    // nicht gleich, gib den maximalen Abstand
    // zurück.

    if (isinf(a) || isinf(b)) return max;

    int32_t ia, ib;
    memcpy(&ia, &a, sizeof(float));
    memcpy(&ib, &b, sizeof(float));

    // Kein Vergleich von Zahlen mit
    // unterschiedlichen Vorzeichen.

    if ((ia < 0) != (ib < 0)) return max;

    // Gib den Absolutwert des Abstandes
    // in ULPs zurück.

    int32_t distance = ia - ib;
    if (distance < 0) distance = -distance;
    return distance;
}

Wir vermeiden den Vergleich von Werten mit unterschiedlichen Vorzeichen aus einer Reihe von Gründen:

  • 1. ULPs sind, wie wir noch sehen werden, nicht das richtige Werkzeug um Werte in der Nähe von oder jenseits von Null zu vergleichen.
  • 2. Nahezu alle modernen CPU benutzen Zweierkomplement-Arithmetik, während Gleitkommazahlen Vorzeichen und Größe benutzen. Die Umwandlung von einem Format in das andere benötigt zusätzliche Arbeit, um sinnvoll Zahlen mit unterschiedlichen Vorzeichen addieren oder subtrahieren zu können. Aus demselben Grund muss das Vorzeichen unseres Ergebnisses nicht das sein, das wir erwarten. Daher nehmen wir den Absolutwert. Uns interessieren nur die Abstände zwischen unseren beiden Eingabewerten.
  • 3. Wenn die Subtraktion einen Über- oder Unterlauf erzeugt, bekommen wir ein undefiniertes Verhalten bei vorzeichenbehafteten Integerzahlen und bei modularer Arithmetik mit vorzeichenlosen Zahlen. Nichts davon ist hier erstrebenswert.

Wir berechnen den Absolutwert aus zwei Gründen selbst, statt std::abs zu verwenden. Erstens akzeptieren die Ganzzahl-Versionen von std::abs nur Datentypen, deren Größe plattform-spezifisch ist, wie int, long oder long long. Wir wollen Annahmen über die implizite Umwandlung zwischen diesen Typen und int32_t vermeiden (auch wenn das zugegebenermaßen etwas an Paranoia grenzt: int32_t ist einer der Datentypen, die es auf fast jeder relevanten Plattform gibt.)

Der zweite Grund ist ein sonderbarer Fallstrick, der mit der Anordnung von std::abs Überladungen in der C++ Standardbibliothek zu tun hat. Wenn Sie <cmath> einbinden, aber nicht <cstdlib>, stehen nur die Gleitkomma-Versionen von std::abs zur Verfügung. Mehrere Toolchains, die ich getestet habe, wandeln einen int32_t Wert in einen double um, auch wenn die Zielplattform nur über eine 32-Bit FPU verfügt und daher double in Integer-Registern emulieren muss (wie man vermuten kann, mit fürchterlichen Auswirkungen auf die Laufzeit). Die Verwendung von Compiler-Warnungen wie -Wconversion kann uns darauf aufmerksam machen, oder wir können alle diese Fallen generell vermeiden, indem wir den Absolutwert direkt berechnen. Jedenfalls ist das ein triviales Detail.

Es gibt keine Wunderwaffe

Relative Epsilons – einschließlich derer, die auf ULPs basieren – machen um Null herum keinen Sinn. Die exponentielle Natur von Gleitkomma-Zahlen bedeutet, dass sich dort viel mehr Werte auf der Zahlengerade versammeln, also irgendwo sonst. Obwohl es im Kontext vieler Berechnungen nur ein verhältnismäßig kleiner Wert ist, ist 0,1 über eine Milliarde ULPs von der Null entfernt!

Konsequenterweise sind also feste Epsilons möglicherweise die beste Wahl, wenn sie kleine Ergebnisse erwarten. Welches ε sie verwenden, hängt vollständig von den durchgeführten Berechnungen ab.

Mit diesem Wissen bewaffnet, besteht nun möglicherweise die Versuchung, eine Funktion wie die folgende zu schreiben, die all dem ein Ende setzt:

bool nearlyEqual(float a, float b,
      float fixedEpsilon, int
          ulpsEpsilon)
{
// Behandlung von Zahlen nahe Null
  const float difference =
      fabs(a - b);
  if (difference <= fixedEpsilon)
      return true ;

  return ulpsDistance(a, b) <= ulpsEpsilon;
}

Aber ein aussagekräftiger Einsatz ist ohne die gerade diskutierte Theorie schwierig.

Ein kurzer Exkurs: Weitere ULP-basierte Funktionen

Dieselbe Technik können wir zum Schreiben weiterer nützlicher Funktionen benutzen. Zum Beispiel für eine Funktion, die eine Gleitkomma-Zahl um eine beliebige Zahl von ULPs inkrementiert. Boost bietet eine ähnlich Familie von Funktionen an (float_next, float_advance, usw.), aber wie bei der float_distance Funktion geht das auf Kosten der Leistung um Type Punning zu vermeiden.

Man könnte jetzt darauf hoffen, dass wir einfach unsere ULPs berechnen, unsere Addition durchführen und dann das Ergebnis mittels memcpy zurückgeben, wie beispielsweise diese Funktion:

// Erweitert f um die gegebene Anzahl von ulps
float ulpsIncrement(float f, int32_t ulps)
{
    if (isnan(f) || isinf(f)) return f;
    int32_t i;
    memcpy(&i, &f, sizeof(float));
    i += ulps;
    memcpy(&f, &i, sizeof(float));
    return f;
}

Diese etwas naive Lösung funktioniert für positive Werte, aber auf der meisten Hardware bedeutet das Inkrementieren einer negativen Gleitkomma-Zahl um eine positive Anzahl von ULPs, dass wir uns von der Null wegbewegen. Das ist wahrscheinlich nicht das, was wir wollen.

Wir haben vorher erwähnt, dass Gleitkomma-Zahlen dem Schema „Vorzeichen – Exponent“ folgen, wogegen die meisten CPUs das Zweierkomplement benutzen.

Um also mit negativen Zahlen arbeiten zu können, müssen wir die Werte in das systemeigene Integerformat der CPU* umwandeln.

(*Auf esoterischer Hardware kann das systemeigene Format ebenfalls dem Vorzeichen – Exponent Schema folgen. In diesen Fällen vertrauen wir darauf, dass der Compiler unsere nutzlose Arbeit weglässt.)

static const int32_t int32SignBit =
    (int32_t)1 << 31;

int32_t floatToNativeSignedUlps(float f)
{
    int32_t i;
    memcpy(&i, &f, sizeof(float));

    // Positive Werte sind im Zweierkomplement
    // und im Vorzeichen – Exponent Schema gleich
    // Für negative Werte, entferne das Vorzeichen
    // und negiere das Ergebnis (subtrahiere
    // von Null).

    return i >= 0 ? i : -(i & ~int32SignBit);
}

Nach der Operation mit den ULPs müssen wir wieder zurückwandeln:

float nativeSignedUlpsToFloat(int32_t ulps)
{
    if (ulps < 0) {
    ulps = -ulps;
    ulps |= int32SignBit;
        }
    float f;
    memcpy(&f, &ulps, sizeof(float));
    return f;
}

Da diese Funktionen nunmehr definiert sind, können wir uns wieder unserem Ziel zuwenden:

float float ulpsIncrement(float f, int32_t ulps)
{
    if (isnan(f) || isinf(f)) return f;
    int32_t i = floatToNativeSignedUlps(f);
    i += ulps;
    return nativeSignedUlpsToFloat(i);
}

Was haben wir gelernt?

Wenn Gleitkomma-Zahlen verglichen werden, dürfen wir nicht vergessen:

  • FLT_EPSILON ist nicht das Gleitkomma-Epsilon, das wir benötigen, außer in den Bereichen [-2,-1] und [1,2]. Der Abstand zwischen angrenzenden Werten hängt von den eigentlichen Werten ab.
  • Beim Vergleich mit einigen bekannten Werten – speziell Null oder Werten in der Nähe von Null – verwenden Sie ein festes ε, das für Ihre Berechnungen Sinn ergibt.
  • Beim Vergleich von Werten, die nicht Null sind, ist ein auf ULPs basierender Vergleich wahrscheinlich die beste Wahl.
  • Könnten sich die Zahlen irgendwo auf der Zahlengeraden befinden, ist eine hybride Form der beiden notwendig. Wählen Sie Ihr Epsilon sorgfältig auf der Basis der erwarteten Ergebnisse.

Danksagungen

Vieles hier wurde von der fantastischen Abhandlung des Themas durch Bruce Dawson in seinem Blog Random ASCII übernommen. Vielen Dank auch an meine Kollegen Evan Thomson und Matt Drees für ihre Beiträge.

Hardwarenahe Softwareentwicklung

Hardwarenahe Softwareentwicklung

18.12.18 - Ein Thema wie hardwarenahe Programmierung in einer Hochsprache sollte es eigentlich gar nicht geben. Dennoch kann auf Grund eingeschränkter verfügbarer Ressourcen oft auf hardwarenahe Softwareprogrammierung nicht verzichtet werden. Wie geht man am besten mit diesem scheinbaren Konflikt um? lesen

Fließkommaeinheiten und DSPs in Mikrocontrollern

Gastkommentar

Fließkommaeinheiten und DSPs in Mikrocontrollern

06.10.16 - Mikrocontroller werden schon seit Jahrzehnten entwickelt. Die Integration von DSPs und Fließkommaeinheiten in diese Bausteine hat aber einige entscheidende Veränderungen mit sich gebracht. lesen

Mikrocontroller-Differenzierung durch innovative Peripherals (Teil 1 von 2)

Co-Prozessoren

Mikrocontroller-Differenzierung durch innovative Peripherals (Teil 1 von 2)

28.09.16 - UART, SPI oder andere Peripherals findet man in jeder MCU-Architektur. Besonders interessant sind aber Features, die eine tatsächliche Differenzierung darstellen und dem Entwickler vollkommen neue Lösungsansätze bieten. Denn zusätzliche Chips oder ein stetiges Heraufsetzen von Prozessorleistung stellt ein Design oft vor größere Probleme. lesen

* Der Autor: Matt Kline ist Software Engineer für Fluke Networks und Betreiber des Software-Entwicklungs-Blogs bitbashing.io. Der Beitrag "Comparing Floating-Point Numbers Is Tricky" wurde lizensiert nach einer Creative Commons Attribution-ShareAlike 4.0 International License. (CC BY-SA 4.0). Übersetzung Richard Oed.

Kommentar zu diesem Artikel abgeben

Schreiben Sie uns hier Ihre Meinung ...
(nicht registrierter User)

Zur Wahrung unserer Interessen speichern wir zusätzlich zu den o.g. Informationen die IP-Adresse. Dies dient ausschließlich dem Zweck, dass Sie als Urheber des Kommentars identifiziert werden können. Rechtliche Grundlage ist die Wahrung berechtigter Interessen gem. Art 6 Abs 1 lit. f) DSGVO.
Kommentar abschicken
copyright

Dieser Beitrag ist urheberrechtlich geschützt. Sie wollen ihn für Ihre Zwecke verwenden? Kontaktieren Sie uns über: support.vogel.de/ (ID: 44970621 / Mikrocontroller & Prozessoren)