jk-jeon / dragonbox

Reference implementation of Dragonbox in C++
Apache License 2.0
588 stars 37 forks source link

When the mantissa of double is 16 digits, seems to_decimal() not 100% correct #51

Closed chengm204 closed 9 months ago

chengm204 commented 9 months ago
__int64_t intpart = getInteger();
__int64_t fractpart = getInteger();
double dbl = fractpart + 0.000000000000000000000005;
dbl =  dbl / std::pow(10, countDigit(fractpart));  // countDigit return 5 when fractpart is 12345
dbl += intpart;

I want to make sure my double binary representation like 123.12345000000000000000000000005. So when I run jkj::dragonbox::to_decimal(dbl), I want to make sure I get 123.12345 (of course by (significand, exponent, and is_negative)), so I can verify. I used above code to generate 10000 values. However when significand/mantissa is 16 digits, quite a lot of the decimal are not accurate although I tried most policies.

if significand/mantissa < 16, so far my test looks ok.

jk-jeon commented 9 months ago

I don't quite understand what do you mean. So you think 123.12345000000000000000000000005 is the correct answer, or 123.12345 is the correct answer? What do you mean by not accurate? Can you give me some more examples?

chengm204 commented 9 months ago

double d= 12345678.87654321; its bin representation could be 12345678.87654320999; or 12345678.87654321001. And I can't use C++ any function to display reliably. Otherwise we don't need these beautiful tools at the first beginning.

I wish my way of 123.12345000000000000000000000005 will force any tool to print 123.12345. Maybe here is a problem by itself.

I used std::to_string(intpart) +"." + std::to_string(fractpart). So I know my double should be 12345678.87654321. I feed 12345678.87654321000000000000000000000005 to to_decimal(). should the result be of 12345678.87654321?

Most my data out of 10000 set are ok but some. For example : -98557617.67322599 becomes -98557617.67322598. and -87279759.99344321 becomes -87279759.9934432.

jk-jeon commented 9 months ago

I still don't understand what are you trying to do.

-98557617.67322599 becomes -98557617.67322598. and -87279759.99344321 becomes -87279759.9934432.

Those have exactly the same binary representations. The reason why -98557617.67322598 is chosen is because it's closer to the true value the binary representation is referring to. The reason why -87279759.9934432 is chosen is because it's shorter but still has the same binary representation.

Can you please elaborate what are you trying to achieve, and what exactly is the problem?

chengm204 commented 9 months ago

What I trying to do is to test the tool's reliability by using my way of generating double values which I know what they are.

On Sat, 25 Nov 2023, 14:21 Junekey Jeon, @.***> wrote:

I still don't understand what are you trying to do.

-98557617.67322599 becomes -98557617.67322598. and -87279759.99344321 becomes -87279759.9934432.

Those have exactly the same binary representations. The reason why -98557617.67322598 is chosen is because it's closer to the true value the binary representation is referring to. The reason why -87279759.9934432 is chosen is because it's shorter but still has the same binary representation.

Can you please elaborate what are you trying to achieve, and what exactly is the problem?

— Reply to this email directly, view it on GitHub https://github.com/jk-jeon/dragonbox/issues/51#issuecomment-1826229226, or unsubscribe https://github.com/notifications/unsubscribe-auth/BCWSL2EPKS2CJYCQPQ33HBTYGGE7LAVCNFSM6AAAAAA7ZZNKZCVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTQMRWGIZDSMRSGY . You are receiving this because you authored the thread.Message ID: @.***>

jk-jeon commented 9 months ago

By "the tool" you mean dragonbox? What's "my own way" precisely?

I gave you an explanation on why certain outputs are generated, and for the purpose of this library those are the correct answers and what you seem to believe should be the answers are actually wrong answers. You still didn't clarify what exactly you want to see as the outputs.

chengm204 commented 9 months ago

Let me try my best to see whether I can make it across.

__int64_t intpart = getInteger(); // assume using random gen to generate 8 digits.
__int64_t fractpart = getInteger();
double dbl = fractpart + 0.000000000000000000000005;
dbl =  dbl / std::pow(10, countDigit(fractpart));  // countDigit return 5 when fractpart is 12345
dbl += intpart;

std::string myDouble = std::to_string(intpart) + "." + std::to_string(fractpart);  // to this effect

I ran the above code 10000 times. I print myDouble so I have these 10000 double values by string. Also in my mind, my actual double value should be myDouble plus 0.000000000000000000000005. For example double imaginedValue = -98557617.67322599000000000000000000000005 (maybe wrong as your comments. But this is a value I thought should be). I call auto v = jkj::dragonbox::to_decimal(imaginedValue); then I expect v.significand == 9855761767322599. But according to my test, it's 9855761767322598.

I can understand your explanation. However when I restricted significand < 16, then all are totally correct. So in a way proved my way could make sense? Otherwise 985576176732259 (15 digits now) could be 985576176732258 (15 digits now) which can also be explained as your above stmt?

jk-jeon commented 9 months ago

I think this is not really an issue with Dragonbox. I recommend you to study how floating-point actually works. There are many reading materials, like this: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html.

Or this seems like a very good introduction: https://floating-point-gui.de/

After you got some basic understanding, then you may be able to understand what the three guarantees advertised in the README actually mean.

chengm204 commented 9 months ago

do you mind repeating the three guarantees here? I might miss them.

Is this:

The precise meaning of roundtrip guarantee might be tricky, as it depends on the notion of "correct parsers". For example, given that significand and exponent are the outputs of Dragonbox with respect to an input floating-point number x of, say, type double, then things like x == significand pow(10.0, exponent) might or might not be the case, because each of the floating-point operations in the expression significand pow(10.0, exponent) can introduce rounding errors that can accumulate to a bigger error. What a correct parser should do is to precisely compute the floating-point number from the given expression according to the assumed rounding rule, and the result must be "correctly rounded" in the sense that only the minimum possible rounding error is allowed. Implementing a correct parser is indeed a very nontrivial job, so you may need additional libraries (like Ryu or double-conversion) if you want to check this roundtrip guarantee by yourself.

But I don't use x == significand * pow(10.0, exponent) to verify again.

jk-jeon commented 9 months ago

The algorithm guarantees three things:

  1. It has the roundtrip guarantee; that is, a correct parser interprets the generated output string as the original input floating-point number. (See here for some explanation on this.)

  2. The output is of the shortest length; that is, no other output strings that are interpreted as the input number can contain less number of significand digits than the output of Dragonbox.

  3. The output is correctly rounded: the number generated by Dragonbox is the closest to the actual value of the input number among possible outputs of minimum number of digits.

chengm204 commented 9 months ago

These are exactly what are I want to confirm. I don't want to take for grant. when significand < 16, they hold. When it's 16, less than 0.5% are not true according to my test which may not be valid. That's why I shared my way how I did it to collect comments where might go wrong.

jk-jeon commented 9 months ago

I mean, to be honest it seems like your understanding of how floating-point works doesn't sound very accurate... If it were accurate you may be able to immediately tell that numbers like 98557617.67322599000000000000000000000005 doesn't really exist in the realm of floating-point numbers...

In short, your test code is not guaranteed to produce the correct result, regardless of that 0.000000000000000000000005 part. Among others, you don't know if the exact number you generated is the shortest possible representation of the number that is being actually stored.

chengm204 commented 9 months ago

I totally agree with you that I don't understand how floating-point works. Let's try another way that how I can test 10000 16-significand double values to verify? In another word, I need to know these 10000 doubles to compare with to_decimal() results. How to make them exactly the same as a purpose instead of : yes, they could be last digit different because of this that theory, yet to_decimal() results are correct.

how to generate these 16 significand double values can be in any controlled way as long as we know their value.

I have confidence in the tool, otherwise I might not spent effort here.

jk-jeon commented 9 months ago

Note that if you want to test if Dragonbox works correctly, then you have to test it against another code that supposedly produces the correct outputs. But how would you know that another code produces the correct output?

The thing is, merely finding the output with the roundtrip/shortest/correct rounding guarantee is already not a trivial task. You simply can't do that with just a few lines of code. Of course, that's unless you use a library that exactly does that, but yeah, will you believe that library then?

The only way to correctly test Dragonbox without having a fairly good understanding of the floating-point inner working is, to test it against supposedly more battle-tested libraries. Either just believe what others have done, or study yourself the concepts thoroughly. This can't be done otherwise, as far as I can tell.

chengm204 commented 9 months ago

I agree with your last comments which will tell that your lib is better, or best. What I want to achieve is that the tool is completely correct as long as a double's significand does not exceed 16.

my way: generate two integers (di and df). and put them together to become a double as di.df. I print di and df as string and use the result as correct result. Now try to see whether to_decimal(di.df) can give me the same result.

Anyway let's close this topic. I came here again is really to close it.

newbie-02 commented 8 months ago

For the dummies evtl. confused by this thread: You can't expect all
16-digit strings to sustain string -> binary64 ( double ) -> string roundtrips.
The precision relation between doubles and decimal values varies with their
magnitude relation, and in some ranges not all 16-digit decimal values have
a matching binary representation. There are just too few binaries available.

E.g. below 1.0 0.9999999999999999, 0.9999999999999998 and 0.9999999999999997
are representable, while 0.9999999999999995 is not.

For one of the values you mentioned it looks like:

98557617.67322603  <- representable, 
---- GAP ---- 
98557617.67322601  <- representable, 
98557617.673226    <- representable, 
---- GAP ---- 
98557617.67322598  <- representable, 
98557617.67322597  <- representable, 
---- GAP ---- 
98557617.67322595  <- representable, 
98557617.67322594  <- representable, 

For that reason the string 98557617.67322599 you provide is lost in the first
decimal string to binary64 conversion. Simply lost, no way to get back.
( In turn above e.g. 1.0 and 10.0 you have more than all 16-digit decimals
'convertable', but also some 17-digiters ( 1.0000000000000002 ), but that
doesn't really help against the confusion. )

Alcaro commented 8 months ago

0.9999999999999999, 0.9999999999999998 and 0.9999999999999997 are not exactly representable as float64 either. The highest exactly-representable 64bit float values below 1.0 are

0.99999999999999988897769753748434595763683319091796875
0.9999999999999997779553950749686919152736663818359375
0.99999999999999966693309261245303787291049957275390625
0.999999999999999555910790149937383830547332763671875
0.99999999999999944488848768742172978818416595458984375
0.9999999999999993338661852249060757458209991455078125
0.99999999999999922284388276239042170345783233642578125
0.99999999999999911182158029987476766109466552734375 0.99999999999999900079927783735911361873149871826171875

where the above are usually rounded to 0.9999999999999999, 0.9999999999999998, 0.9999999999999997, etc, since those are the shortest strings where the closest representable float is the desired one.

But none of the above rounds to 0.9999999999999995. Therefore, 0.9999999999999995 is not a possible output from Dragonbox; passing 0.9999999999999995 to an accurate float parser will return the same value as the input 0.9999999999999994.

newbie-02 commented 8 months ago

@Alcaro ... :-) you are welcome _hairsplitter
the values you mentioned are not 'representables', but are the binary64
values, exactly converted to and described by their decimal weight.
'being representable' was meant as 'having a binary available as deputy for the
decimal! value'. You know about such things, and I hope we made it clear
enough that dummies with no prior knowledge can understand.
( That's better than 'read Goldberg', that won't help, they won't understand. )

chengm204 commented 8 months ago

@Alcaro @newbie-02 , Very glad your new inputs helping me to understand this binary to decimal conversion concept/issues.

When I tested this lib, I randomly generated 10000 double numbers by restricting their significant say to 11 by my way as describe above, the lib can "get back" correctly. Same for significant 12, 13, 14, and 15. Can I say this not "representables/gap" issue will only happen when significant reaches 16 and beyond (according to the stmt: and in some ranges not all 16-digit decimal values have a matching binary representation. There are just too few binaries available.)?

According to "What every computer scientist should know about floating-point arithmetic (acm.org)" "Squeezing infinitely many real numbers into a finite number of bits requires an approximate representation. " issue starts to appear when significant reaches 16?

jk-jeon commented 8 months ago

@chengm204 That's certainly not true in the strongest sense imaginable, because the effective number of significand digits for so-called subnormal numbers can be much less than 15. Your test likely didn't generate that much of subnormal numbers to make this visible to you.

However, for normal numbers, that is indeed the case. You can find such a statement in the Wikipedia page; see the section IEEE 754 double-precision binary floating-point format: binary64.

Roughly speaking, this is because double (more precisely, IEEE-754 binary64) has 53 bits for the binary significand. Ignore the exponent part both in decimal and in binary, then this roughly means that the gap between adjacent representable numbers is $2^{-53}\approx 1.11\times 10^{-16}$. Hence, the gap is bigger than $10^{-16}$, but is smaller than $10^{-15}$, so there cannot be more than two numbers with at most 15 decimal significand digits in the interval of real numbers corresponding to a single instance of IEEE-754 binary64. That means, if a number with at most 15 decimal significand digits is converted to an instance of IEEE-754 binary64, then that number must be the unique number with at most 15 decimal significand digits corresponding to the same instance of IEEE-754 binary64.

(More precise explanation that takes into account of the exponent part is also possible, but the take away is that it doesn't change the overall picture that much.)

chengm204 commented 8 months ago

@jk-jeon May I say market price should be normal number because these are for people to read? How to create subnormal number by c++ code automatically?

May I also say that int.int assigned to a double var my way to create a double is a normal number? Just that my sample of 98557617.67322599 normal number happens belonging to 5% according to the wiki page 53log10(2) = 15.955. 16-15.955 = 5%

Thanks.

jk-jeon commented 8 months ago

@chengm204 Your method actually cannot generate any single subnormal numbers. The largest subnormal number is of the order of 10^-310.

If you generate IEEE-754 binary64 uniformly randomly (which is far from what you're doing), the chance of having a subnormal number is $1/2048\approx$ 5% $0.05$%.

Alcaro commented 8 months ago

1/2048 is not 5%. It's 0.05%.

jk-jeon commented 8 months ago

@Alcaro Lol. Right. I was fool.

chengm204 commented 8 months ago

Hi Gentlemen. First of all Merry X'mas!

Here is my takeway:

"The largest subnormal number is of the order of 10^-310." is a very small number. 98557617.67322599 in binary should be a subnormal number. I did not get the point.

newbie-02 commented 8 months ago

@chengm204 : still no,

chengm204 commented 8 months ago

@newbie-02

Let me try to summarize to see whether I have fully understood the concept:

• For normal number, if significand <= 15, guaranteed roundtrip to proper decimal from binary64. • Largest subnormal number starts from 1E-309 (10^-310). For subnormal numbers: the smaller the value the less digits significand /representable. • SRT(algo/lib?, Significand/Representable T) can understand subnormal number up to 1E-323? • For normal number, if significand > 15, most likely a roundup/get-back failure happens to 'high' values starting with 9, 8, 7 ... very rare if any for leading digit 1 .. 4 . • All these normal number failures plus un-representable subnormal account 0.05% of total binary64 values. • 5E-325 is the only representable after 1E-323?

newbie-02 commented 8 months ago

Let me try to summarize to see whether I have fully understood the concept:

you look somewhat persistent in trying to mis-understand ... no, binary floating point numbers are not! designed to follow your or mine 'simple thinking'.

• For normal number, if significand <= 15, guaranteed roundtrip to proper decimal from binary64. ( a round-trip isn't 'from binary', but 'binary -> decimal -> binary' and / or 'decimal -> binary -> decimal',

• Largest subnormal number starts from 1E-309 (10^-310). For subnormal numbers: the smaller the value the less digits significand /representable.
( no, 'largest subnormal' is 0 00000000000 (0). 11111111111111111111111111111111111111111111111111 in bits, weighting ~ 2.225073858507201E-308 in decimal for binary64. )

• SRT(algo/lib?, Significand/Representable T) can understand subnormal number up to 1E-323? ( no, 'SRT' stands for Shortest Round Tripping )

• For normal number, if significand > 15, most likely a roundup/get-back failure happens to 'high' values starting with 9, 8, 7 ... very rare if any for leading digit 1 .. 4 .
( no, it's a complex concept of 'round to nearest, ties to even' ( binary! ) if a un-representable 16-digit decimal will become rounded up or down )

• All these normal number failures plus un-representable subnormal account 0.05% of total binary64 values. ( no, looking 'from binary' you won't get 'failures', whereever a binary64 converts to a 16-digit decimal that 16-digit decimal will / shall / should convert back to the origin binary64 value. But your experimenting started from decimal values, and some of them are not representable in binary64. I haven't counted, assume it near ( 16 - 15.954589770191001 ) * 100 percent ( of normals ) for 'normals'-range, and over 99 % ( of possible 16-digit decimal values in binary64 denormals range ) for denormals. If you need distinct numbers or in general to pimp up your knowledge I propose to take www.weitz.de/ieee , stiff the values in and count success in getting back what simple minded users would expect vs. slightly different results. ).

• 5E-325 is the only representable after 1E-323?
( no,
A: you need to define your scope, the 5E-324 is the Shortest Round Tripping decimal correspondent to the smallest available subnormal binary64 value above 0, 0 00000000000 (0). 0000000000000000000000000000000000000000000000000001 in bits, weighting appr. 4.9406564584124654E-324 in decimal. The situation is different in e.g. binary32, binary128 ... .
B: you need to define your 'direction', 'before' or 'after' depends on your POV ( Point Of View ) looking 'from 0' the situation is different to looking from 'above',
C: you need to account '0' and negative values? looking 'from 0' there are a lot of subnormals and then a huge number of 'normals' after 1E-323, looking from 'above 1E-323' there is 5E-324 'after' it, then 0, then -5E-324, then -1E-323, then about 4.5035996273704930E15 negative subnormal binary64 values, and then a huge number of 'normal' negative binary64 figures. )

All above isn't and can't be a complete correction of your mis-understandings, nor a complete explanation of binary floating point numbers. For further investigations I propose:

chengm204 commented 8 months ago

@newbie-02

I'll not try to summarize it. May I say my previous experiment is decimal-->binary-->decimal trip and is one valid test? If yes, then first of all, all my input are normal data? And (16 - 15.954589770191001 ) * 100 percent (roughly 5% and these 5% are all 16 digits normal number) are not representable in bindary64 (current C++ double)? So these normal numbers will fail to finish binary-->decimal journey?

chengm204 commented 8 months ago

It seems my decimal-->binary-->decimal trip understanding above looks ok.

newbie-02 commented 8 months ago

..... please ......
use this tool: www.weitz.de/ieee and see your input 98557617.67322599

chengm349 commented 7 months ago

@newbie-02

Trying to understand all your previous information as a picture:

image

newbie-02 commented 6 months ago

@chengm349 ... It's hard to say, but you appear to me to be one of the toughest 'I don't want to understand' cases I've ever met.
But you are putting effort into learning, that's commendable, maybe you should find another field.
Have you worked through the study tour I gave you in previous comment It wasn't a joke, it definitely made sense and is necessary for you.
Please try to enter 1.234567890123456E-320 in the Weitz Calculator and see what happens, and try to understand 'SRT' as 'shortest round tripping' decimal string for a binary representation' which means decimal -> binary64 approximation, binary64 approximation -> SRT decimal, SRT decimal -> the same binary64 approximation. And try to understand subnormals as 'graceful degradation', a ramp down from ~16 digits precision at the biggest to only 1 digit precision at the smallest one.