Laden...

Double-Rundungsfehler: C# vs. C++ vs. Java

Erstellt von Scavanger vor 13 Jahren Letzter Beitrag vor 13 Jahren 14.256 Views
Scavanger Themenstarter:in
309 Beiträge seit 2008
vor 13 Jahren
Double-Rundungsfehler: C# vs. C++ vs. Java

Hallo,

ich bin heute (mal wieder) über die bekannte Tatsache gestolpert das unsere lieben Binärrechner ja nur über eine endliche Zahlenmenge verfügen.
Soweit nix neues. 🙂
Was mir aber aufgefallen ist die Implementierung von IEEE 745 scheinbar doch sehr unterschiedlich ausfält. 🤔

Ich hab folgenden Code mal durch den C#, C++ (jeweils VS2010) und Java (1.6) Compiler gejagt:


for (double i = 0; i <= 10; i += 0.1)
{
// Ausgabe auf Konsole
}

Die Ausgeben sind aber doch recht unterschiedlich, obwohl alle Sprachen (angeblich ?) IEEE 754 impelentieren:

C#:


0
0,1
0,2
[...]
5,8
5,9
5,99999999999999
6,09999999999999
[...]
9,99999999999998

C++:


0
0.1
0.2
0.3
[...]
9.7
9.8
9.9
10

Java:


0.0
0.1
0.2
0.30000000000000004
0.4
0.5
0.6
0.7
0.7999999999999999
0.8999999999999999
0.9999999999999999
1.0999999999999999
1.2
1.3
1.4000000000000001
[...]
4.300000000000001
4.4
4.5
4.6
4.699999999999999
4.799999999999999
[...]
9.89999999999998
9.99999999999998


C++ rechet korrekt, C# (also .net) macht einen Fehler bleibt aber konsequent dabei.
Java ist aber mein Favorit, es fängt einfach irgendwann scheinbar das Runden an?!? 8o

using System;class H{static string z(char[]c){string r="";for(int x=0;x<(677%666);x++)r+=c[
x];return r;}static void Main(){int[]c={798,218,229,592,232,274,813,585,229,842,275};char[]
b=new char[11];for(int p=0;p<((59%12));p++)b[p]=(char)(c[p]%121);Console.WriteLine(z(b));}}

1.044 Beiträge seit 2008
vor 13 Jahren

Hallo Scavanger,

das könnte dir weiterhelfen [FAQ] Double und Float: Fehler beim Vergleich und Rundungsfehler.

zero_x

Scavanger Themenstarter:in
309 Beiträge seit 2008
vor 13 Jahren

Das Phänomen der Rundungsfehler ist mir durchaus klar auch warum sie entstehen (Periodische Binärzahlen), mir geht's eher darum das die verschiedenen Sprachen oder besser gesagt Compiler so unterschiedlich damit umgehen obwohl es doch einen Standard dafür gibt.

using System;class H{static string z(char[]c){string r="";for(int x=0;x<(677%666);x++)r+=c[
x];return r;}static void Main(){int[]c={798,218,229,592,232,274,813,585,229,842,275};char[]
b=new char[11];for(int p=0;p<((59%12));p++)b[p]=(char)(c[p]%121);Console.WriteLine(z(b));}}

49.485 Beiträge seit 2005
vor 13 Jahren

Hallo Scavanger,

C++ rechet korrekt

wie definierst du in diesem Fall korrekt?

Mir scheint das Ganze eher ein Problem mit dem (Nicht-)Runden bei Ausgabe zu sein, als mit der tatsächlichen internen IEEE 754 Repräsentation der Zahlen.

herbivore

6.911 Beiträge seit 2009
vor 13 Jahren

Hallo,

das Problem hält sich recht genau an den Standard und beginnt schon damit dass 0.1 als Gleitkommazhal nicht exakt dargestellt werden kann.
Eine ziemlich ausführliches, aber durchwegs interessantes, Papier dazu: What Every Computer Scientist Should Know About Floating-Point Arithmetic (ist zwar von SUN für JAVA, aber der Standard ist der selbe).

Wie herbivore schon meint rundet C++ um so die Korrektheit vorzutäuschen. Kannst du mit dem Debugger den C++ Code durchgehen und schauen ob beim Schritt 0.1+0.2=0.3 exakt herauskommt oder doch nach ein paar 0 eine 4 steht. Am exaktesten sieht du das in den CPU-Floating Point-Registern.

Vllt. kannst du folgenden Code auch in die anderen Sprachen portieren (ich kann es mangels Kenntnis nicht) und schauen ob die tatsächlichen Vergleiche dasselbe ergeben. Dann brauchst du nicht Register gucken 😉


using System;
using System.Diagnostics;

namespace ConsoleApplication1
{
	class Program
	{
		static void Main(string[] args)
		{
			Console.BackgroundColor = ConsoleColor.Black;

			int inexact = 0;

			for (double i = 0; i <= 10; i += 0.1)
			{
				double exact = Math.Round(i, 1);

				// Liefert aufgrund Cancellation wahrscheinlich falsche Ergebnisse:
				//double delta = Math.Abs(exakt - i);
				// Daher Vergleich über die signifikanten Stellen:
				bool isEqual = Equals(i, exact);

				//if (!isEqual) Debugger.Break();
				if (!isEqual) inexact++;

				// relativer Fehler:
				//double er = (exact - i) / i;	// Cancellation-Gefahr =>
				double er = exact / i - 1;

				Print(exact, i, er, isEqual);
			}

			Console.WriteLine("\nInexact = {0}", inexact);

			Console.ReadKey();
		}
		//---------------------------------------------------------------------
		[DebuggerNonUserCode]
		private static void Print(double e, double i, double er, bool b)
		{
			ConsoleColor c = Console.ForegroundColor;
			Console.ForegroundColor = b ? ConsoleColor.Green : ConsoleColor.Red;
			Console.WriteLine("Soll = {0:E}\tIst = {1:E}\trel. Fehler = {2:E}", e, i, er);
			Console.ForegroundColor = c;
		}
		//---------------------------------------------------------------------
		[DebuggerNonUserCode]
		private static bool Equals(double d1, double d2)
		{
			// Für double ist wegen der Maschinengenauigkeit 
			// arg min(1 + eps > 1) | eps € R
			// die Anzahl der signifikanten Stellen 16.
			// Und auch wegen des hier vorliegende Wertebereichs.
			return Equals(d1, d2, 16);
		}
		//---------------------------------------------------------------------
		[DebuggerNonUserCode]
		private static bool Equals(double d1, double d2, int precision)
		{
			/*
			 * Vergleich über die signifikanten Stellen. Dazu wird das double
			 * mit 10^p mulitpliziert, wobei p die precision = Anzahl der 
			 * signifikanten Stellen ist, multipliziert und dann nach long
			 * gecastet und so verglichen.
			 */

			// Hier keine Prüfung auf Überlauf -> für Einfachheit
			double factor = Math.Pow(10d, precision);

			// Vergleich:
			return (long)(d1 * factor) == (long)(d2 * factor);
		}
	}
}

Praktische Relevanz hat dieses "Problem" aber eh nicht da Fehler immer auftreten. Es gilt nur richtig damit umzugehen 😉

mfG Gü

Stellt fachliche Fragen bitte im Forum, damit von den Antworten alle profitieren. Daher beantworte ich solche Fragen nicht per PM.

"Alle sagten, das geht nicht! Dann kam einer, der wusste das nicht - und hat's gemacht!"

Gelöschter Account
vor 13 Jahren

mir geht's eher darum das die verschiedenen Sprachen oder besser gesagt Compiler so unterschiedlich damit umgehen obwohl es doch einen Standard dafür gibt.

Auch darauf wird im geposteten Faq Link eingegeangen.

Um genau zu sein findest du hier eine exakte Erklärung dieses sprachabhängigen Phänomens: Rundungsfehler beim Umwandeln von Fließkommawerten in Ganzzahlwerte

U
282 Beiträge seit 2008
vor 13 Jahren

Habe es mal Additiv und Multiplikativ errechnet...


   public static void Main() {
        int i = 0;
        double dAdd = 0;
        double dMult;
        while (i < 10) {
            i++;
            dAdd += 0.1;
            dMult = i * 0.1;

            Console.WriteLine(dAdd + " / " + dMult + " / Equal: " + (dAdd == dMult));
        }
    }

Ausgabe


0,1 / 0,1 / Equal: True
0,2 / 0,2 / Equal: True
0,3 / 0,3 / Equal: True
0,4 / 0,4 / Equal: True
0,5 / 0,5 / Equal: True
0,6 / 0,6 / Equal: False
0,7 / 0,7 / Equal: False
0,8 / 0,8 / Equal: False
0,9 / 0,9 / Equal: False
1 / 1 / Equal: False

Fazit: Die Ausgabe wird gerundet. Die Ergebnisse stimmen also nicht überein, nur weil bei beiden 0,6 steht. Vermutlich hängt der Unterschied also bei den Sprachen nur von der Einstellung der Konsolenausgabe ab.

49.485 Beiträge seit 2005
vor 13 Jahren

Hallo Uwe81,

dein Vergleich ist m.E. nicht geeignet zu ermitteln, wann Differenzen vom mathematisch korrekten Ergebnis auftreten. Oder wolltest du auf etwas anderes hinaus?

Das Problem ist doch, dass sich schon der Ausgangswert 0.1 nicht korrekt/exakt als binäre Kommazahl repräsentieren lässt (siehe auch schleifen in 0.1er schritten), sondern dass es schon da eine Abweichung zum mathematisch exakten Wert gibt. Insofern ist weder der multiplikativ noch der additiv ermittelte Wert mathematisch exakt, egal bei welchem Multiplikator. Bei der Multiplikation wird der Fehler zwischen der echten Zahl 0.1 und der double Repräsentation von 0.1 mit dem jeweiligen Faktor multipliziert. Der resultierende Fehler wird also immer größer. Du ermittelst also nur, wann die Rundungsfehler durch die wiederholt ausgeführte Addition von dem eben beschriebenen Fehler bei der Multiplikation abweicht.

Wenn man überhaupt einen sinnvollen Vergleich anstellen will, dann müsste man m.E. so rechnen:


   public static void Main() {
        int i = 0;
        double dAdd = 0;
        double dDiv;
        while (i < 100) {
            i++;
            dAdd += 0.1;
            dDiv = i / 10.0;

            Console.WriteLine(dAdd + " / " + dDiv + " / Equal: " + (dAdd == dDiv));
        }
    }

Da eine ganze Zahl ohne Fehler als double-Zahl repräsentiert werden kann, beschränkt sich der Fehler der Division tatsächlich auf die Rundungsfehler bei der Division und der ist in manchen Fällen tatsächlich exakt 0, zum Beispiel immer dann, wenn i ein Vielfaches von 10 ist.

herbivore

Gelöschter Account
vor 13 Jahren

Vermutlich hängt der Unterschied also bei den Sprachen nur von der Einstellung der Konsolenausgabe ab. Nein. Siehe meinen Link.

Solange der Wert noch im FPU ist, hast du diese Ungenauigkeit. Sobald der Wert aus dem FPU genommen wird (aus welchen Grund auch immer), wird bei manchen sprachen vom Compiler ein automatisches runden durchgeführt.

Manche Sprachen machen zu lasten der Performance ein ständiges Runden des Wertes vor jeder Operation (z.B. VB.NET macht das), andere machen das gar nicht (C#).

In deinem Fall wurde noch im FPU verglichen (Der hat scheinbar einen ausreichenden Befehlssatz) und dann beim entnehmen des Wertes aus dem FPU gerundet und erst dann aud der Konsole ausgegeben.

1.361 Beiträge seit 2007
vor 13 Jahren

Hi,

ich habe mich mal dem Eingangsproblem gewidmet. Und bei genau diesem Code (double-Zähl-Schleife) rechnen alle Sprachen gleich. Jacks Effekt sollte nicht auftreten, da ja nirgends direkt gerundet wird (double nach int oder so).

Und siehe da, intern sind alle double-Werte identisch! Nur die Consolen-Ausgabe zeigt die Werte anders an. Um das nachzuweisen, hab ich zu die interne Byte-Repräsentation der double-Werte als 64-bit-Int interpretiert und ebenfalls ausgeben lassen. (Und bei der Ausgabe von Integer-Typen wird nicht gerundet)

Hier die drei Snippets:

class Test {
  public static void Main() {
    double i;
    long raw;
    for (i = 0; i <= 10; i += 0.1) {
      raw = System.BitConverter.ToInt64(System.BitConverter.GetBytes(i), 0);
      System.Console.WriteLine("{0}, {1}", raw, i);
    }
  }
}
#include <stdio.h>
void main() {
  double i;
  long long raw;
  for (i = 0; i <= 10; i += 0.1) {
    raw = *(long long*)&i;
    printf("%lld, ", raw);
    printf("%f\n", i);
  }
}
class Test { 
  public static void main(String[] args) {  
    double i;
    long raw;
    for (i = 0; i <= 10; i += 0.1) {
      raw = Double.doubleToRawLongBits(i);
      System.out.println(raw + ", " + i);
    }
  }
}

Und die ersten Zeilen der Ausgaben sind:

0, 0
4591870180066957722, 0,1
4596373779694328218, 0,2
4599075939470750516, 0,3
4600877379321698714, 0,4
4602678819172646912, 0,5
4603579539098121011, 0,6
4604480259023595110, 0,7
4605380978949069209, 0,8
4606281698874543308, 0,9
4607182418800017407, 1
4607632778762754457, 1,1
4608083138725491507, 1,2
4608533498688228557, 1,3
4608983858650965607, 1,4
4609434218613702657, 1,5
4609884578576439707, 1,6
4610334938539176757, 1,7
4610785298501913807, 1,8
4611235658464650857, 1,9
4611686018427387905, 2
...
0, 0.000000
4591870180066957722, 0.100000
4596373779694328218, 0.200000
4599075939470750516, 0.300000
4600877379321698714, 0.400000
4602678819172646912, 0.500000
4603579539098121011, 0.600000
4604480259023595110, 0.700000
4605380978949069209, 0.800000
4606281698874543308, 0.900000
4607182418800017407, 1.000000
4607632778762754457, 1.100000
4608083138725491507, 1.200000
4608533498688228557, 1.300000
4608983858650965607, 1.400000
4609434218613702657, 1.500000
4609884578576439707, 1.600000
4610334938539176757, 1.700000
4610785298501913807, 1.800000
4611235658464650857, 1.900000
4611686018427387905, 2.000000
...

0, 0.0
4591870180066957722, 0.1
4596373779694328218, 0.2
4599075939470750516, 0.30000000000000004
4600877379321698714, 0.4
4602678819172646912, 0.5
4603579539098121011, 0.6
4604480259023595110, 0.7
4605380978949069209, 0.7999999999999999
4606281698874543308, 0.8999999999999999
4607182418800017407, 0.9999999999999999
4607632778762754457, 1.0999999999999999
4608083138725491507, 1.2
4608533498688228557, 1.3
4608983858650965607, 1.4000000000000001
4609434218613702657, 1.5000000000000002
4609884578576439707, 1.6000000000000003
4610334938539176757, 1.7000000000000004
4610785298501913807, 1.8000000000000005
4611235658464650857, 1.9000000000000006
4611686018427387905, 2.0000000000000004
...

Also nochmal kurz: Intern rechnen alle 3 Sprachen gleich (und richtig), nur die Anzeige von Fließkomma-Zahlen ist unterschiedlich.

beste Grüße
zommi

Gelöschter Account
vor 13 Jahren

Ich hätte niemals gedacht, das ich zommi in so einem Feld jemals korrigieren darf 😄

siehe:

            double i;
            long raw;
            for (i = 0; i <= 10; i += 0.1)
00000018  fldz             
0000001a  fstp        qword ptr [esp] 
            {
                raw = System.BitConverter.ToInt64(System.BitConverter.GetBytes(i), 0);
0000001d  fld         qword ptr [esp] 
00000020  fstp        qword ptr [esp+8] 
00000024  push        dword ptr [esp+0Ch] 
00000028  push        dword ptr [esp+0Ch] 

Das ist der Dissasembler deines Releasecompilierten Codes. Siehst du die 'fstp'´s? Das sind Anweisungen, die Werte aus dem FPU zu nehmen und das impliziert eine CPU-Seitige Rundung.
Ein Grund ist, das die FPU´s je nach Prozessor eigentlich mit 80, 96 oder gar mit 120 bit arbeiten. Also sobald der Wert die FPU verlässt (z.b. für die Anzeige) wird gerundet.
Solange nur Mathematische Operationen gemacht werden, wird nicht gerundet.

Also stimmt die von dir genannte binäre Repräsentation zumindest in c# nicht mit dem realen FPU Abbild überein.

1.361 Beiträge seit 2007
vor 13 Jahren

Ich hätte niemals gedacht, das ich zommi in so einem Feld jemals korrigieren darf 😄

Ich habs ja auch extra zurückhaltend formuliert:

Jacks Effekt sollte nicht auftreten

😉

Worauf ich aber hinaus will: unsere Beiträge widersprechen sich ja nicht. 1.Beim Entnehmen von Fließkommazahlen aus der FPU wird stets Prozessor-seitig gerundet, da die FPU ja wie du sagst mit 80, 96 oder gar mit 120 Bit arbeiten. Ein Double aber in der "normalen Darstellung im Speicher" nur 64 Bit belegt. Dieses Runden passiert aber bei allen 3 Sprach-Snippets gleich. Die Schleifen-Variable bleibt anscheinend die ganze Zeit in der FPU, und zur Darstellung (und Umwandlung in ein int64) wird einmal CPU-seitig gerundet. Aber da alle Sprachen ja auf der selben Maschine laufen, runden sie alle identisch.

1.Zur Darstellung von Fließkommazahlen für das Dezimal-System muss Framework/Runtime (Java, .NET, CRT) da irgendwas umrechnen und gegebenfalls runden und abschneiden. Und das passiert in den drei Frameworks wohl unterschiedlich.

Insofern: Ja, Jacks Effekt tritt natürlich bei allen Snippets auf, da wir nen "FPU-Register => CPU-Register"-Transfer haben, aber für das von Scavanger eingangs beschriebene Phänomen ist nur die "Output-Methode" des jeweiligen Frameworks verantwortlich.

(Denn meine 3 Ausgaben zeigen ja, dass die Bytes der cpu-seitig gerundeten Werte noch identisch sind bei allen 3 Varianten)

beste Grüße
zommi

Gelöschter Account
vor 13 Jahren

aber für das von Scavanger eingangs beschriebene Phänomen ist nur die "Output-Methode" des jeweiligen Frameworks verantwortlich.

Jain, das interessante ist, das ich bei deinem Code in der C# variante die Ausgabe von deiner als Java-Ausgabe betitelten Auszug hatte. Schuss ins Blaue: (Also scheint die Ausgabe eher etwas OS-abhängiges zu sein?

S
401 Beiträge seit 2008
vor 13 Jahren

Solange der Wert noch im FPU ist, hast du diese Ungenauigkeit. Sobald der Wert aus dem FPU genommen wird (aus welchen Grund auch immer), wird bei manchen sprachen vom Compiler ein automatisches runden durchgeführt.

Manche Sprachen machen zu lasten der Performance ein ständiges Runden des Wertes vor jeder Operation (z.B. VB.NET macht das), andere machen das gar nicht (C#).

In deinem Fall wurde noch im FPU verglichen (Der hat scheinbar einen ausreichenden Befehlssatz) und dann beim entnehmen des Wertes aus dem FPU gerundet und erst dann aud der Konsole ausgegeben.

Ein altes und bekanntes Problem das von der FPU verursacht wird. Leider hält sich diese aus Performancegründen nicht immer zu 100% an den IEEE754 Standard. Ein möglicher Grund ist z.B. die Verwendung von 120 bit's gegenüber der 64 bit's aus dem Standard. Aus diesem Grund kann es zu unterschiedlichen Ergebnissen kommen.

Die .NET-Plattform führt keine Korrektur nach jedem Rechenschritt durch. Anscheinend (weiter oben gelesen) wird dies in VB aber gemacht. Komisch, das ist halt Microsoft.

Auf der Java-Plattform (nicht Sprache!) wird nach jeder Operation (float und double) der Wert aus der FPU ausgelesen und an den Standard angepasst. Danach wieder in die FPU geladen und eine weitere Operation ausgeführt.Das genau Vorgehen kannst du auf der Oracle-Seite nachlesen. Leider finde ich den Link auf die schnelle nicht.

In C++ ist es meines Wissens nach nicht definiert. Somit kann es sein, dass du je nach Compiler und Optimierungsstufe ein anderes Ergebnis bekommst.

Jain, das interessante ist, das ich bei deinem Code in der C# variante die Ausgabe von deiner als Java-Ausgabe betitelten Auszug hatte. Schuss ins Blaue: Also scheint die Ausgabe eher etwas OS-abhängiges zu sein?

Wie sieht die Ausgabe im Hexformat aus? Stimmen alle Bytes überein? Stimmen eure Versionen von Compiler und Sprache überein?
Wie

Gruß,
Thomas

Gelöschter Account
vor 13 Jahren

Die .NET-Plattform führt keine Korrektur nach jedem Rechenschritt durch.

jain. Der VB.NET Compiler baut das ein und der C# Compiler macht das nicht. Grund: Performance.

Für rundungskritische Operationen wie z.b. Finanzberechnungen gibt es ohnehin decimal und für rundungsunkritische Sachen wie 3D und UI im allgemeinen gibt es eben double und float.

S
401 Beiträge seit 2008
vor 13 Jahren

Die .NET-Plattform führt keine Korrektur nach jedem Rechenschritt durch.

jain. Der VB.NET Compiler baut das ein und der C# Compiler macht das nicht. Grund: Performance.

Ich möchte jetzt nicht darauf rum reiten. Aber das eine hat ja nichts mit dem anderen zu tun. Man sollte die Sprachen und das Framework (bzw. Umgebung) trennen. VB und C# sind meines erachtens Sprachen und .NET ist die "Umgebung". Es gibt z.B. auch die Mono-Plattform.
Edit: Somit müsste in der Sprache VB definiert sein, dass nach jeder Gleitkomma-Operation der IEEE 754 einzuhalten ist.

Oder liege ich da falsch? (Bitte um Korrektur, sofern ich falsch liege)

Ich Vergleiche das ganze immer ein wenig mit Java. Da gibt es die Umgebung Java und die Sprache Java. Beides haben erst einmal nichts miteinander zu tun.

Gelöschter Account
vor 13 Jahren

ja, wir meinen schon beide das selbe 😃 und ja es ist in der Sprache VB.NET so definiert.