Rundungsfehler beim Umwandeln von Fließkommawerten in Ganzzahlwerte

Veröffentlicht: 21. Okt 2005
Von Claudio Ganahl

Ein älterer Mathematiklehrer wird gefragt, wie viel 2 plus 2 ergibt. Er holt seine Logarithmentafeln heraus, murmelt vor sich hin und antwortet nach einiger Zeit: 3,999857. Jeder Pentium-Prozessor würde sich dieser Antwort sofort anschließen. So richtig dumm wird es, wenn diese Zahl anschließend falsch auf 3 statt auf 4 gerundet wird. Gibt's nicht? Doch, gibt's. dotnetpro warnt vor Rundungsfehlern.

dotnetpro


Diesen Artikel können Sie dank freundlicher Unterstützung von dotnetpro auf MSDN Online lesen. dotnetpro ist ein Partner von MSDN Online.
Ausgabe 05/2005


Zur Partnerübersichtsseite von MSDN Online

Bei der Arbeit mit Floating-Point-Zahlen, besonders bei deren Umwandlung in Integer-Zahlen, ist größte Vorsicht geboten. Jeder weiß, dass bei Floating-Point-Zahlen Rundungsfehler auftreten können. Dass diese sich jedoch schon bei der zweiten Nachkommastelle bemerkbar machen, ist den meisten Programmierern nicht bewusst und wird entsprechend wenig beachtet.

Um dies zu demonstrieren, werden hier zwei Versionen eines Beispielcodes analysiert:

Version 1 in C++/C#

float f = 10.57f;
int i = (int) (f*100);

Im ersten Moment erwartet man, dass nach der Ausführung beider Anweisungen in der Integer-Variablen i der Wert 1057 steht. Eine Überprüfung mit dem Debugger ergibt aber, dass dort der Wert 1056 abgespeichert ist! Wenn Sie obigen Code ausprobieren, werden Sie feststellen, dass das Verhalten sowohl in C# als auch in C++ dasselbe ist.

Fürs Erste ist das ein unerklärliches Verhalten. Bevor Sie den Effekt genauer unter die Lupe nehmen, sehen Sie sich bitte folgenden Code an:

Version 2 in C++/C#

float f = 10.57f;
f = f * 100;
int i = (int) f;

Diese Anweisungen führen tatsächlich zum Wert 1057 in der Integer-Variablen. Wiederum ist das Verhalten sowohl in C++ als auch in C# dasselbe. Was führt nun aber zu dem eigentümlichen Verhalten, und worin liegt der Unterschied zwischen den beiden Programmteilen?

Um den exakten Unterschied der beiden Programmfragmente zu analysieren bleibt Ihnen nichts anderes übrig, als die daraus generierten Maschinenbefehle zu vergleichen.

Sehen Sie sich den Assembler-Code an, den der C++-Compiler für den Intel- Pentium-Prozessor generiert hat. Ein C#- Programm sieht nach dem JIT-Compiler- Durchlauf praktisch identisch aus:

Version 1 in Assembler

float f = 10.57f;
00411C0E mov          dword ptr [f],41291EB8h

int i = (int) (f*100);
00411C15 fld          dword ptr [f]
00411C18 fmul         dword ptr
[__real@42c80000 (4280C8h)]
00411C1E call         @ILT+1375(__ftol2)
(411564h)
00411C23 mov          dword ptr [i],eax

Für die weiteren Erklärungen ist es notwendig zu wissen, dass der Intel-Pentium- Prozessor ein Floating-Point-Rechenwerk (FPU - Floating Point Unit) besitzt, das intern mit 80-Bit-Genauigkeit rechnet. Dieses Rechenwerk besitzt auch einen eigenen Floating Point Stack (FPS), auf dem 80 Bit breite Operanden für Floating-Point-Rechenoperationen gelegt werden können. Was passiert nun im Detail?

float f = 10.57f;
00411C0E mov          dword ptr [f],41291EB8h

In die Variable f wird der Wert 10.57 mit 32-Bit-Genauigkeit geschrieben.

int i = (int) (f*100);
00411C15 fld          dword ptr [f]

Die 32-Bit-Variable f wird auf den FPS gelegt. Der Inhalt von f, nun mit einer Genauigkeit von 80 Bit interpretiert, entspricht aber dem Zahlenwert 10.569999694824218.

00411C18 fmul         dword ptr
[__real@42c80000 (4280C8h)]

Die Zahl auf dem FPS wird mit 100 multipliziert, es steht also nun dort 1056.9999694824218.

00411C1E call         @ILT+1375(__ftol2) (411564h)
00411C23 mov          dword ptr [i],eax

Zu guter Letzt wird dieser Wert in eine Ganzzahl umgewandelt, das heißt, die Nachkommastellen werden einfach abgeschnitten und das Ergebnis der Variablen i zugewiesen. So kommt es dazu, dass dort jetzt 1056 steht.

Der Prozessor führt eigentlich folgenden Code aus:

float f = 10.57f;
int i = (int) ((double80) f * 100);

Der Datentyp double80 existiert natürlich in einer Hochsprache nicht, entspricht aber dem 80-Bit-Floating-Point-Register des Pentium-Prozessors.

Version 2 in Assembler

float f = 10.57f;
00411C0E mov          dword ptr [f],41291EB8h
f = f * 100;
00411C15 fld          dword ptr [f]
00411C18 fmul         dword ptr
[__real@42c80000 (4280C8h)]
00411C1E fstp         dword ptr [f]
int i = (int) f;
00411C21 fld          dword ptr [f]
00411C24 call @ILT+1375(__ftol2)
(411564h)
00411C29 mov          dword ptr [i],eax

Der generierte Code von Version 2sieht, bis auf die Tatsache, dass das Ergebnis von f = f * 100 einmal vom Stack genommen, in die Variable f geschrieben und anschließend wieder auf den Stack gelegt wird, exakt gleich aus. Genau in diesem scheinbar kleinen Detail liegt auch der Ursprung des seltsamen Verhaltens. Eine Analyse bringt es ans Licht:

float f = 10.57f;
00411C0E mov          dword ptr [f],41291EB8h
f = f * 100;
00411C15 fld          dword ptr [f]
00411C18 fmul         dword ptr
[__real@42c80000 (4280C8h)]

Die ersten Anweisungen stimmen exakt mit denen von Version 1 überein. Auf dem FPS befindet sich nach der Multiplikation ebenfalls der Wert 1056.9999694824218.

00411C1E fstp dword ptr [f]

Dieser 80 Bit breite Wert wird mit der Anweisung fstp vom Stack genommen und in die 32-Bit-Fließkommazahl-Variable f gespeichert. Wird aber ein Wert vom Floating Point Stack genommen und in einen 16-, 32- oder 64-Bit-Speicherplatz geschrieben, so führt die FPU des Prozessors eine Rundung durch.

Im vorliegenden Fall wird der Wert 1056.9999694824218 auf exakt 1057 gerundet und anschließend in die Variable f geschrieben.

int i = (int) f;
00411C21 fld          dword ptr [f]
00411C24 call         @ILT+1375(__ftol2) (411564h)
00411C29 mov          dword ptr [i],eax

Zum Schluss wird in der zweiten Version der Wert von f wieder auf den FPS gelegt, in gewohnter Weise in eine Integer- Zahl konvertiert und der Variablen i zugewiesen. Ebenfalls in einer Pseudohochsprache ausgedrückt, führt das Programm folgende Anweisung durch:

float f = 10.57f;
int i = (int) (float) ((double80) f * 100);

Der hier konstruierte Fehler tritt dann auf, wenn eine Floating-Point-Zahl mit 32 Bit nicht exakt ausgedrückt werden kann. Das Tückische dabei ist, dass dies nur bei bestimmten Fließkommawerten auftritt. Beim Einsatz des Datentyps double anstelle von float ist ein Auftreten des Fehlers zwar unwahrscheinlicher, da von vornherein mit 64-Bit- genauigkeit gearbeitet wird. Die Problematik ist aber im Prinzip dieselbe. Schon bei drei Nachkommastellen tritt dasselbe Rundungsproblem wieder auf.

double d = 1.001;
int i = (int) (d*1000);

Um das beschriebene Verhalten im Debugger nachzuvollziehen, müssen Sie direkt die Prozessorregister inklusive der Werte auf dem FPS ausgeben. In der Disassembler- Sicht kann dann Schritt für Schritt das Verhalten analysiert werden. Dies ist mit der derzeitigen Entwicklungsumgebung VS Studio 2003 nur für C++-Programme möglich. Aber auch hier werden nicht die exakten Werte des FPS mit 80 Bit Genauigkeit angezeigt. Der Debugger wandelt den 80-Bit-Wert auf dem FPS für die Ausgabe auch in einen 64-Bit- Double um, was zur Ausgabe von 17 signifikanten Stellen im Debug- Fenster führt. In Abbildung 1 ist der wahre Wert der Variablen f, nachdem er auf den FPS gelegt wurde, im Register ST0 sichtbar. Es ist interessant, dass der Effekt nicht aufgetreten wäre, wenn der Prozessor ein lediglich 32 Bit breites Floating-Point-Rechenwerk besäße. Es kommt nämlich zum Fehler, weil der 32 Bit breite Wert mit dem genaueren 80 Bit breiten Rechenwerk des Pentium-Prozessors verarbeitet wird. Bei einem Test auf einem Pocket PC (HP Jornada) trat der Rundungsfehler in keiner der Versionen auf. Dabei ist aber wichtig festzuhalten, dass das Programm tatsächlich auf dem Pocket PC laufen muss und nicht im Emulator getestet wird, da dieser ja wiederum die 80 Bit breite FPU vom Entwicklungsrechner verwendet. Ein Fehler aufgrund der höheren Rechengenauigkeit mutet eigenartig an.

Das Register ST0 zeigt den korrekten Wert der Variablen f an
Abbildung 1 Das Register ST0 zeigt den korrekten Wert der Variablen f an.

Die Problematik ist also grundsätzlich nicht von der gewählten Hochsprache, sondern vom Prozessor abhängig, es sei denn, der Compiler der gewählten Sprache erzeugt den Code bereits so, dass das Verhalten nicht auftritt. Dies kann anhand von VB.NET gezeigt werden.

Version 1 mit VB.NET

Dim f As Single = 10.57
Dim i As Integer = f * 100

Der VB.NET-Compiler übersetzt das Programmfragment so, dass nach dem JIT- Durchlauf folgender Assemblercode erzeugt wird:

Dim f As Single = 10.57
00000011 mov          dword ptr [ebp-4],41291EB8h
Dim i As Integer = f * 100
00000018 fld          dword ptr [ebp-4]
0000001b fmul         qword ptr ds:[00EE0098h]
00000021 fstp         dword ptr [ebp-14h]
00000024 fld          dword ptr [ebp-14h]
00000027 frndint

Ohne den Code genau zu diskutieren, ist ersichtlich, dass das Ergebnis der Multiplikation, bevor es der Integer-Variablen zugeordnet wird, vom FPS genommen und gleich wieder auf diesen gelegt wird. Auf Kosten der Performance wird der Rundungseffekt vermieden.

Um wirklich sicher zu sein, dass der Fehler, unabhängig vom Prozessor des Zielsystems, nicht auftritt, empfiehlt es sich, für Umwandlungen von Fließkommazahlen in Integer-Werte immer Methoden der Klasse Math des .NET Frameworks zu verwenden.

float f = 10.57f;
int i = (int) Math.Round(f * 100);

Dieser Code liefert unabhängig vom verwendeten Prozessor immer den richtigen Wert.

Wenn Sie abschließend den Intermediate-Language-( IL-)Code des VB.NET- Programmfragments in Abbildung 2 mit dem vom C#-Compiler generierten IL-Code in Abbildung 3 vergleichen, so werden Sie feststellen, dass der VB.NETCompiler genau diesen Aufruf automatisch bei der Umwandlung von Fließkommatypen in ganzzahlige Werte einfügt, während der C#-Programmierer selbst dafür verantwortlichist.

Der VB.NET-Compiler rundet bei der Umwandlung von Fließkommazahlen in ganzzahlige Werte richtig
Abbildung 2 Der VB.NET-Compiler rundet bei der Umwandlung von Fließkommazahlen in ganzzahlige Werte richtig.

Der C#-Programmierer muss selbst für die richtige Rundung sorgen
Abbildung 3 Der C#-Programmierer muss selbst für die richtige Rundung sorgen.


Claudio Ganahl ist Teilhaber der Software Consulting GmbH Teslab. Er arbeitet als Berater, Entwickler und Trainer im Bereich objektorientierter Software-Entwicklung unter C# und C++. Sie erreichen ihn unter claudio.ganahl@teslab.com.


Anzeigen: