Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

numeric: false positive equality with f32 and 64 #5180

Closed
UweKrueger opened this issue Jun 2, 2020 · 19 comments
Closed

numeric: false positive equality with f32 and 64 #5180

UweKrueger opened this issue Jun 2, 2020 · 19 comments

Comments

@UweKrueger
Copy link
Member

V version: V 0.1.27 076089d.b0f66a4
OS: Manjaro Linux 20.02 x86_64

What did you do?

fn main() {
    q_e := -1.602176634e-19
    m_e := 9.1093837015e-31
    if q_e == m_e {
        println('$q_e and $m_e are equal')
    }
    if q_e < m_e {
        println('$q_e is smaller than $m_e')
    }
}

What did you expect to see?
-1.60218e-19 is smaller than 9.10938e-31

What did you see instead?
-1.60218e-19 and 9.10938e-31 are equal
-1.60218e-19 is smaller than 9.10938e-31

Discussion
The issue is of cause caused by the comparison function f64_eq() that checks
f64_abs(a - b) <= DBL_EPSILON
DBL_EPSILON has the constant absolute value 2.22e-16 - the distance between two consecutive f64 numbers in the range 1.0..2.0. So this function has no tolerance effect for numbers > 2.0 and has too much tolerance for small numbers. A better approach might be checking with a relative tolerance like in
f64_abs(a - b) <= f64_abs(a)*(2.5*DBL_EPSILON)

However, actually I think implementing such checks in the v core language is not really a good idea and I'd like to discuss this issue. Here are the points I see:

  • standard equality checks without tolerance are well understood and conformant to IEEE754
  • if there is a demand for checks with tolerance than it is usually application specific. The 2.5 above is just a guess, there might be cases where a bigger relative tolerance is needed and there are other cases where an absolute tolerance is appropriate. So there is no canonical way for the v core language
  • f64 multiplications are somewhat expensive
  • there are a lot numeric algorithms that are designed and tested with standard checks in mind
  • even though some problems (numbers seem different but should be equal) caused by the limited precision of f64 can be reduced with tolerant checks others become worse (numbers that should differ seen to be equal).
  • from the mathematical point of view only one of the comparisons <, == and > should evaluate to true
  • people that learn to program should become aware of the intrinsic problems of limited precision and should investigate them. Hiding these problems is somewhat counterproductive

For these reasons I'd like to propose using standard equality checks in the v core language. Any thoughts?

@UweKrueger UweKrueger added the Bug This tag is applied to issues which reports bugs. label Jun 2, 2020
@UweKrueger UweKrueger changed the title numeric: false positive equaulity with f32 and 64 numeric: false positive equality with f32 and 64 Jun 2, 2020
@JalonSolov
Copy link
Contributor

I would vote to just use standard equality checks. As you say, this is a fairly well-known problem, with far too many possible "solutions" - the programmer has to decide which is "right" in their situation.

@spytheman spytheman added Type: Discussion and removed Bug This tag is applied to issues which reports bugs. labels Jun 2, 2020
@medvednikov
Copy link
Member

@UweKrueger I agree.

@penguindark
Copy link
Member

@UweKrueger I agree too 👍

@dumblob
Copy link
Contributor

dumblob commented Jun 5, 2020

@UweKrueger thanks for pointing me to this issue.

I actually must diasgree with most of the argumentation points. Let's walk through them one by one 😉.

First of all I think the new epsilon-comparison is way closer to what is described in the golden article about comparing floats (a must read for everyone who uses floating point numbers for serious stuff - i.e. 0.01% of programmers). Thanks a lot @UweKrueger for implementing that!

  • standard equality checks without tolerance are well understood and conformant to IEEE754

That sounds like an argument why to actually make tolerance equality check a default (i.e. ==) in V. But maybe I misunderstood.

  • if there is a demand for checks with tolerance than it is usually application specific. The 2.5 above is just a guess, there might be cases where a bigger relative tolerance is needed and there are other cases where an absolute tolerance is appropriate. So there is no canonical way for the v core language

I'd argue this is absolutely not relevant because we're just deciding defaults and by default 99.9% of good programmers do just know, that floats are getting less precise with increasing value. Very few might even have a rough idea how the function of precision approximately looks like in a given range. But none (yes, I mean it) would know how to implement e.g. ULP (Units in the Last Place) based checking for their floats - in other words they'd also be unable to choose from different options provided (e.g. between AlmostEqualUlpsAndAbs() and AlmostEqualRelativeAndAbs() taken from the linked article).

So, they'll just use the method they themself can explain & understand (i.e. the dumbest constant absolute value as difference). But only if you tell them, that == is as dumb as in other "older" languages. And that's what defaults should definitely fight against.

  • f64 multiplications are somewhat expensive

Floating point numbers are always very expensive. This is no surprise, so no argument on this side. If you want sacrifice correctness & precision in favor of speed, use intrinsic .biteq(). Easy as that.

  • there are a lot numeric algorithms that are designed and tested with standard checks in mind

Yes, perfect. It's the exact opposite use case than default. Algorithms and code designed & tuned for bitwise equality checks have nothing to do with the programmer and her programming. She doesn't programm it, but just copies it over with .biteq() and she's done. So no argument here either. Rather vice versa - it emphasizes that the given special algorithm is deliberately designed for bitwise comparisons.

  • even though some problems (numbers seem different but should be equal) caused by the limited precision of f64 can be reduced with tolerant checks others become worse (numbers that should differ seen to be equal).

This I don't understand. For me those are two very different use cases which should not be mixed under any circumstances. Could you name a use case where you work with the same number in both ways (once you prefer it to be equal to something in an equality check, but on the next line in the same "mental context" you prefer it to differ from something in an equality check)?

If there are any such use cases, then V should imho do as much as it can to avoid or warn or just make it difficult to write them, because that sounds like a perfect programming anti-pattern.

  • from the mathematical point of view only one of the comparisons <, == and > should evaluate to true

One would argue, that it's not a big issue in practice, but I'll agree. What V could do is to disallow < and > for floats and allow just <= and >= which kinda makes sense for floats in general (and of course provide bitle() and bitgt() intrinsics).

  • people that learn to program should become aware of the intrinsic problems of limited precision and should investigate them. Hiding these problems is somewhat counterproductive

I totally do agree with that. But I think this is irrelevant for the defaults which we discuss here. My proposal above to disallow < > in compile-time is one of the things which would (IMHO sufficiently) educate the 99.9% of programmers. You won't educate programmers by letting them write bad code (see my rant above about their inability to understand & use ULP comparisons in favor of e.g. fixed delta) which without any way to notice it's bad behavior runs in production "forever".

V would become one of the few by default float-safe languages in the world and would significantly increase the precision and quality of float arithmetics in the world. I think those are the goals of V and not vice versa. Thus I think having bitwise equality check of floats as the default is too premature and very unsafe decision.

@spytheman your thoughts?

@UweKrueger
Copy link
Member Author

@dumblob Thanks for your detailed reply. I think it goes without saying that I disagree... ;-)

I do completely agree that in most cases float should be compared with a tolerance. But as your linked article says: "There is no silver bullet. You have to choose wisely." The v compiler cannot have the wisdom to make the correct choice. For v as a general purpose programming language (that's at least how I see it) it doesn't make sense to make any one default choice since it may fit for one purpose and not for the other.

V would become one of the few by default float-safe languages in the world and would significantly increase the precision and quality of float arithmetics in the world.

Can you give me an example for any other language that does checks with tolerance by default? (lua and Javascript don't, I've checked.) I would like to investigate how it is implemented.

BTW: I've figured out another point: Equality is supposed to be a transitive relation. From a=b and a=c follows b=c. Now imagine:

a := 12.234567890123456
b := 12.234567890123464
c := 12.234567890123447
println('${(a==b)} ${(a==c)} ${(b==c)}')
println('${a.eq_epsilon(b)} ${a.eq_epsilon(c)} ${b.eq_epsilon(c)}')

result as of V 0.1.27 076089d.0aadde2:

false false false
true true false

The first result is totally understandable: we do have three different numbers. This is what I meant with "well understood and conformant to IEEE754".

The second result can also be understood when taking into account that we are checking with a tolerance. eq_epsilon() reminds you that we are not checking for equality and can't expect transitivity.

If the first line would yield the second result it would be a big surprise and not understandable in the mathematical sense (it's one of the problems that "become worse"). It's not "float-safe" and does not "significantly increase the precision". Actually the tolerance decreases the precision and leads to unexpected results - possibly at deferred points in time when they become harder to debug.

This example also shows: The real problem is not the comparison: The three numbers are different and the default == comparison just correctly says so. The problem are calculations that produce different results when in the mathematical sense they shouldn't. With tolerant checks only the symptom is cured - not the cause. And in longer calculations this cause might accumulate to higher errors so one has to use a higher value for Ɛ. The tolerant check must be adjusted to the problem which can't be done by the compiler.

@penguindark
Copy link
Member

Just to make a note:
First we must consider the conversion from string to float a := 12.234567890123456
in this operation lay a lot of "imprecision" and in a second stage the operation itself (like a sum) can erode the precision further.
we must have a compromise between speed and precision, keeping in mind that absolute precision in floats is not achievable.

@dumblob
Copy link
Contributor

dumblob commented Jun 5, 2020

@dumblob Thanks for your detailed reply. I think it goes without saying that I disagree... ;-)

😉

I do completely agree that in most cases float should be compared with a tolerance. But as your linked article says: "There is no silver bullet. You have to choose wisely." The v compiler cannot have the wisdom to make the correct choice. For v as a general purpose programming language (that's at least how I see it) it doesn't make sense to make any one default choice since it may fit for one purpose and not for the other.

And that's where our experience wildly differs. I argue, that in all cases when the programmer doesn't explicitly tell the computer to do bitwise equality checks, the programmer does not care about the "different purposes". You argue that the programmer in all cases need to distinguish whether she does care about "different purposes" or not disregarding whether there is an additional construct offering tolerance equality check or not.

BTW: I've figured out another point: Equality is supposed to be a transitive relation. From a=b and a=c follows b=c. Now imagine:

a := 12.234567890123456
b := 12.234567890123464
c := 12.234567890123447
println('${(a==b)} ${(a==c)} ${(b==c)}')
println('${a.eq_epsilon(b)} ${a.eq_epsilon(c)} ${b.eq_epsilon(c)}')

result as of V 0.1.27 076089d.0aadde2:

false false false
true true false

The first result is totally understandable: we do have three different numbers. This is what I meant with "well understood and conformant to IEEE754".

I think this is quite wrong, because what that example shows is IMHO two different cases:

  1. The compiler didn't tell you hey, your explicitly wished a value can't be represented so there be dragons. That's a bug. Has nothing to do with floating point equality checking.
  2. I here you saying "but what about such computed values". Then I'll tell you that the answer above is correct (i.e. expected), because IEEE754 does NOT represent the mathematical real number, but just an approximation which towards infinity quickly looses its precision. Easy as that. In other words, even bitwise comparison wouldn't help you here and would just give you a false feeling that "it works" when it doesn't.

The second result can also be understood when taking into account that we are checking with a tolerance. eq_epsilon() reminds you that we are not checking for equality and can't expect transitivity.

If the first line would yield the second result it would be a big surprise and not understandable in the mathematical sense (it's one of the problems that "become worse"). It's not "float-safe" and does not "significantly increase the precision". Actually the tolerance decreases the precision and leads to unexpected results - possibly at deferred points in time when they become harder o debug.

See my comment above. First floating point in hardware has nothing to do with mathematics (as I said above - IEEE754 deliberately says it's not mathematically correct, it's just a convenient approximation of chosen mathematical constructs and operations). Second, tolerance equality checking does NOT decrease precision, it actually (at least in case of ULPs) precisely follows the IEEE754 standard. So it absolutely does NOT get worse.

This example also shows: The real problem is not the comparison: The three numbers are different and the default == comparison just correctly says so. The problem are calculations that produce different results when in the mathematical sense they shouldn't. With tolerant checks only the symptom is cured - not the cause. And in longer calculations this cause might accumulate to higher errors so one has to use a higher value for Ɛ. The tolerant check must be adjusted to the problem which can't be done by the compiler.

Again, there is no cure needed - it's defined and expected behavior of HW-implemented approximating floating point arithmetic. Bitwise comparison won't help at all in these situations - it'll just significantly cut down the space of meaningful use cases which would be possible if tolerance equality check was the default.

@dumblob
Copy link
Contributor

dumblob commented Jun 5, 2020

we must have a compromise between speed and precision, keeping in mind that absolute precision in floats is not achievable.

If talking about "how to implement tolerance check", then yes.

But keep in mind, there is nothing like "compromise between speed and precision" in general with regards to HW-backed floting point. Precision is fixed (by HW FPU capabilities), it won't get worse, but it also won't get any better.

And speed is not a question - intrinsics are fast as an assembly instruction. And speed of tolerance equality checking is a concern of second level - the first level is correctness (i.e. as I noted above - following the IEEE754 standard precisely e.g. by implementing ULP checking).

@dumblob
Copy link
Contributor

dumblob commented Jun 5, 2020

I forgot to mention for those trying to see V as a mathematical language, that equality of real numbers is generally undecidable (see https://en.wikipedia.org/wiki/Real_number#In_computation , https://math.stackexchange.com/a/143964 , etc. ).

So mathematically speaking by that logic V shouldn't even allow any comparison between any floating point numbers 😉. But that's an obvious nonsense, so let's stick to pragmatism & simplicity and treat operator comparisons as tolerance equality check.

@dumblob
Copy link
Contributor

dumblob commented Aug 18, 2020

Before V will freeze its semantics in one of the upcoming releases, please read the following and act accordingly. Thank you.

I did a lot more thorough research and found out, that floating point is far worse than you would ever think. This sharply contradicts the general belief of most commenters here that "it's well understood". No, it's NOT - read further.

First there are several standards in use. The most prominent one - IEEE 754 - being significantly revised every few years. The second most widespread is the one found in POWER architecture (btw. POWER10 implements at least 3 inherently different floating point standards!). And there are others - for us especially all those supported by the language C. This all means, V must not assume anything about the underlying floating point standard (so please no IEEE 754 arguments any more 😉).

Second, there is nothing like bitwise equality in IEEE 754. Simply because e.g. IEEE 754 mandates, that +0 is always equal to -0 despite both having different bit representations of course. So C's operator == having float or double on at least one of its sides does NOT do bitwise comparison. So we should definitely rename our "wannabe bitwise" routines to avoid confusion.

Third, floating point implementations (be it in hardware FPU or software microcode or programming language compiler or operating system or an arbitrary mixture of these) are basically never correct nor similar in behavior across computing machines (incl. virtual ones). Some notable examples (by far not comprehensive):

  • PS2 FPU has the last bit of mantissa undefined.
  • Some FPUs are simply non-standard in that they produce an unexpected result.
    • E.g. result of na operation differs by more than one ULP (from left and/or from right or both) from the correct (expected) one. Often though this difference is 2 ULPs (instead of 1 ULP if correctly computed), but it can be more.
  • Some FPUs follow standards, but have flush-to-zero or subnormals-to-zero or both modes turned on by default (and it usually can't be turned off because the FPU in such case does not support such "normal" mode).
  • FPUs round differently (e.g. IEEE 754 suggests round-to-even mode by default which is not what one would intuitively expect, but it's the best known to eliminate any type of bias) and this is not always changeable to what's needed (different FPUs support different rounding modes).
  • Some FPUs do not do subnormal (aka "denormal") floating point calculations themself, but rather trap and microcode/firmware does it (e.g. many Intel Itaniums do this) which is magnitudes slower.
  • Compilers are free to do "subtle mistakes" in comparisons (even in <= and >= in addition to ==) - i.e. compare with higher precision (e.g. 80bit instead of 64bit), but then not mask/truncate the result in the register, but directly compare it thus leading to severe (and extremely difficult to discover) deffects.
  • Some wide-spread floating point implementations (e.g. many ARMs do that) have neither little nor big endian floating point number representations.

Fourth, these are some languages having built-in floating point equality well-defined (unlike many which ignore all the above issues).

  • APL (fixed epsilon - i.e. absolute tolerance, changeable)
  • APLx (fixed epsilon ⎕CT of 1e-13 for 32bit floating point and 3e-15 for 64bit floating point - i.e. absolute tolerance, both changeable)
  • A+ (fixed epsilon of 1e-13 for 64bit floating point - i.e. absolute tolerance, non-changeable)
  • Julia builtin ≈ operator (is both dynamic & relative; good source of inspiration but being "overly sensitive" in -1..1 interval and not counting subnormals as zero)
  • J (same mechanism as APL)
  • kdb+ and Q (fixed epsilon - i.e. absolute tolerance, non-changable; Q is a wrapper of K, K/Q powers nowadays the biggest stock markets in the world as their backend!)
  • Wolfram Mathematica bultin operator == (aka Equal): Approximate numbers with machine precision or higher are considered equal if they differ in at most their last 7 bits (roughly their last two decimal digits).
    • https://mathematica.stackexchange.com/questions/76896/different-floating-point-numbers-equal
    • btw. this is an interesting concept based on the idea, that the programmer has chosen the underlying float format bit size very closely to the magnitude range her values will have - in practice though it doesn't work well because there are basically only very few floating point number sizes (e.g. f32 f64) with fixed precision ranges, which is by far too coarse to make this concept work well
  • Pyret (fixed epsilon of 1e-6 compared to mean of operands - i.e. relative tolerance, changeable; dedicated syntax for anything inexact including everything floating point related)
  • Raku aka Perl 6 builtin operator =~= (fixed epsilon of 1e-15, changeable)
  • and I could probably go on (especially with scientific, financial and numerical computation languages; not so much with languages missing seamless productivity from their existential goals[1])...

[1]: I hope V won't join this non-productive club.

Btw. Lua indeed doesn't do anything else than C comparison though by default Lua compares itself to 32bit integers which can be losslessly represented by the Lua's number (which is 64bit floating point).

Fifth, I wrote some testing programs (in C, in Julia, in Mathematica) to see different aspects of floating point implementations and I can confirm what is written above.

All in all floating point implementations (not only those in hardware!) are notoriously full of mistakes and of incomplete features - and that'll be true for future chips and software platforms as well (this follows also from the fact, that e.g. IEEE 754 is being developed, revised and amended more or less every now and then).

So, the bottom line is, that any non-approximating comparison (such as plain == >= <= in C) in a cross-platform language is undefined and thus absolutely useless due to extreme incompatibilities. Such comparison must never be the default (any default must have a well-defined cross-platform behavior). In this light any approximating comparison is better than undefined and certainly wrong behavior (despite there is no approximating comparison working perfectly in all cases whis is anyway an oxymoron).

Ask yourself whether you knew all of the above (I didn't 😢).

IMHO the easiest (and uncontroversial) would be to disallow == >= <= < > operators for floats/doubles completely and to add API for 5 cases (assuming module float with enum Op { lt gt le ge eq }):

  1. float.csig( l any_float, op Op, r any_float, nsigdig u8 )

    Comparison of N digits from the first significant digit/figure in base-10 representation (not to be confused with number of decimal places/digits) and of the first sigdigit index/position/place. I'll use "sigdigits" for the N digits even if not all of them are necessarily "significant digits". This is very similar to what Excel does.

    N will be an optional argument of comparison intrinsics (defaulting to 5 which was empirically designated). If both operands have less than N sigdigits due to being too close to inf, then they compare as false and in non-prod builds a warning will be issued (the user should have chosen different float type or different N or different scale or combination thereof to avoid this case).

    For completeness sake negative zero equals positive zero and zero itself is treated like always having satisfactory number of sigdigits at right places and being equal to any (negative or positive) subnormal to account for issues outlined above. If N is 0, then the comparison will only check whether the first sigdigit starts at the same decimal place (10==20 and inf==inf but 10!=2). N shall be smallest available unsigned integer or any unsigned integer if faster in the target environment/machine. N greater than base 10 logarithm of the max number representable by the chosen float ("log10maxoftype") is compile time error if N is a constant expression. Otherwise warning "will silently use log10maxoftype instead of N if N too big" in non-prod builds shall be issued (in -prod builds only the capped N shall be silently used).

    Note also that sigdigits by definition compare to zero only if it's a clean zero or subnormal (this elegantly avoids the painful situation to decide "when exactly the number begins to be considered zero" while leaving things well behaved and well defined). Last but not least sigdigits also elegantly avoid the "subtraction of the same number shall give zero" scenario - again by definition.

    This thus seems most intuitive and suitable for scientific as well as ordinary calculation (the implementation can also be made quite efficient).

    Note, blind use of significant figures for computation (i.e. truncation) is a bad idea, but that's not the case here (we're just comparing and not truncating).

  2. float.cabs( l any_float, op Op, r any_float, eps )

    Absolute tolerance (defaulting to machine epsilon, but changeable).

    This is for compatibility reasons with software around. I'd probably not recommend it for V code though because not everybody knows e.g. that f64 has precision as low as 2.0 already for 1.0e16 (i.e. fairly small number) nor that in interval 0.0..1.0 there is basically the same count of representable numbers as in interval 1.0..inf. I.e. precision of some "everyday" numbers ("I had 2.15 USD in my pocket.") is magnitudes better than precision of generally any i64 converted to f64 like "And that contributed to the 20_513_000_000_000_000 USD GDP." (this wouldn't be true for integers fitting into f34 or smaller, but why would you use i64 if not for bigger numbers => QED). In this example you could not simply compare/add/subtract the two numbers because the GDP would have precision (4.0 btw.) completely overlapping the amount of USDs in your pocket and thus the GDP number either wouldn't change at all or would change unexpectedly (you better stay with i64 next time).

    Another subtle disadvantage is that the programmer will not know whether her chosen tolerance technically makes sense (i.e. is "big enough" to account for differences, otherwise the comparison will always be false). A common issue here could be though partially mitigated by a compile-time check in case one of the operands is a constant expression (i.e. evaluated in compile-time) whose result is smaller than the given epsilon.

  3. float.cper( l any_float, op Op, r any_float, percent u8 )

    Within percentage approach (similar to Pyret).

    This might be more intuitive than (2) in certain scenarios. It has the same minor disadvantage as (2).

  4. float.culp( l any_float, op Op, r any_float, nulp u16 )

    Comparison with tolerance of N units in the last place (ULPs).

    This is useful for advanced floating point calculations (e.g. to account for technical stuff like accumulated error over a sequence of float operations despite the round-to-even rule frequently used by FP units) and for compatibility e.g. with some game engines.

  5. float.ccpp( l any_float, op Op, r any_float )

    C/C++ comparison operators (they are not bitwise and they form a union of functionality of different standards - not just IEEE 754 - through being stateful).

    This intrinsic is solely for compatibility reasons with libraries etc. and should be strongly discouraged for any V code.

(a good "test case" is a comparison of size of an atom to the Planck length or "linked comparison" like if 0.3 != 0.0 then assert (0.3 - 0.3) == 0.0)

For (1) (2) (3) (4) operator < is simply defined as negation of >= (> analogically). Another thing to keep in mind: -prod builds must never panic during floating point comparisons.

An open question regarding this 5-case API is, whether it shall be extended by means for specification of behavior in the "linked comparison" case (i.e. 0.3 != 0.0 => 0.3 - 0.3 == 0.0). In other words, shall we make comparisons stateful? I can imagine something like "savepoints" during computation e.g. with the meaning "store the current position of first sigdigit" and since then always compare from this position (and warn if the other operand has it also defined, but different). Thoughts?

Having just an API has also the advantage, that V can be extended by allowing == < > <= >= for floats/doubles/... any time in the future when some experience with the API in real apps will already exist and satisfactory default semantics could be determined (or become ubiquitous in the computing world, who knows). Note, that these default operators must be aligned with default floats representation on output & input to avoid confusion (i.e. if sigdigits shall become the default, then the output must always show exactly this number of sigdigits, no less, no more).

Btw. comparison operators have a strong influence on e.g. floats as keys in maps - currently it's undefined how to withdraw a value from such a map (#8556 ).


Related notable implementations for study purposes include SYCL-BLAS (combination of relative and absolute), Googletest (plain ULPs difference - they use 4 of them), Unity using 350 ULPs in many functions, Excel (uses 15 significant figures - 15 is way too close to the underlying precision and thus issues arise; the underlying format is double), ...

Btw. note, that mathematically real numbers do not have any infinity, so again as in the above post, we can't take any inspiration from them (there are extended real numbers having +-infinity, but that doesn't apply here either).

@medvednikov, @UweKrueger, @spytheman, @ulises-jeremias, @helto4real

@JalonSolov
Copy link
Contributor

I think by "well understood" most people understand very well that it's useless to try comparing regular computer floating point numbers for exact equality. Regardless of what novices expect.

It is also "well understood" that the more options you give, the more inventive ways people will get things wrong, and then complain that it didn't work exactly as they expected.

I applaud your diligence in researching the options, but I still wonder if this level of effort is required for core V. Perhaps instead we could have a good, high-precision external module for those who understand what they're doing, and need what you've proposed.

The fact that this has only been implemented in a few highly specialized languages, and only in external modules for others, hints at a lack of great need.

@dumblob
Copy link
Contributor

dumblob commented Aug 19, 2020

most people understand very well that it's useless to try comparing regular computer floating point numbers for exact equality

It's not (just) about equality. It's about < > as well as they also lead to undefined behavior - look at least at all the issues I pointed out above. It's worse than you think. Please accept this fact.

It is also "well understood" that the more options you give, the more inventive ways people will get things wrong, and then complain that it didn't work exactly as they expected.

Yes. That's why I "fight" for disabling all ordinary operators for floats. Whether V will have any other API is a separate issue.

But I'd like to reach consensus on disabling the ordinary undefined operators. Then we can discuss API/modules/whatever.

in a few highly specialized languages

This is a strong exaggeration. Note also, that most of these languages are older than most of other languages which do not have built-in floating point equality well-defined (in other words "newer languages seem more crappy in that they promote undefined behavior").

@penguindark
Copy link
Member

Only a dumb proposal:
Have the normal operators work as today with all their limitation, in most of the case they are mroe than sufficient.
But if you include a module, for example named "prec_float", the operators will be overloaded with the new algos inside the modules.
This is not a problem with a simple solution unfortunately.

@JalonSolov
Copy link
Contributor

Agreed, and I would love to see it solved.

I'm certainly not against the idea of having 100% precise floating point operations, but what cost would that have on compile AND run times?

One of the biggest selling points of V is that it is so fast, and if adding this extra precision slowed it by a significant amount, that point goes away.

@dumblob
Copy link
Contributor

dumblob commented Aug 19, 2020

Have the normal operators work as today with all their limitation, in most of the case they are mroe than sufficient.

My point is to get rid of undefined behavior in defaults. And letting float operators be as they are now wouldn't meet this criterion 😢.

But if you include a module, for example named "prec_float", the operators will be overloaded with the new algos inside the modules.

This is a very cool and simple idea, thanks! This basically makes the language itself instrumentable (which is awesome on its own!). Don't know though whether V supports that.

I'd be all for that (of course under the condition, that the default behavior without this module loaded would disallow all these float operators completely and first loading this module would allow their usage).

One of the biggest selling points of V is that it is so fast, and if adding this extra precision slowed it by a significant amount, that point goes away.

No worries, the 5. case (float.ccpp( l any_float, op Op, r any_float )) is an intrinsic, so no performance difference to ordinary C/C++ float operators.

And basically all other cases can be implemented very efficiently (even the 1. case as demonstrated by the popularity of Excel).

@dumblob
Copy link
Contributor

dumblob commented Jan 13, 2021

As a follow up on #5180 (comment) , it seems due to equality operators being now overloadable we got much closer to the proposal of disabling all equality operators for floats by default and allowing to import any of the 5 proposed APIs with file-grained granularity.

@dumblob
Copy link
Contributor

dumblob commented Feb 23, 2021

Related: #8840 (comment)

@dumblob
Copy link
Contributor

dumblob commented Mar 14, 2021

Motto:

We're all the way on a wrong path. Why to talk about approximate floating point if we have "exact" floating point (up to a certain point, of course)? Let's forget binary floating point (f32 f64 etc.) and make decimal floating point the default.

(digging deeper in the rabbit hole of the wrong path did not bring any fruit anyway)

Now I'm pretty certain the best approach would be to abandon supporting f32 f64 by default and instead introduce d1 d2 d3 ... d34 where the number after d represents a minimum number of base-10 decimal digits it shall maintain for floating point representation. Non-integer number literals would always be base-10 decimals if neither suffix nor casting would be used (any trailing 0s would increase precision by the same number of base-10 decimal digits). IEEE 754 decimal128 corresponds to d34, decimal64 to d16 and decimal32 to d7 (everything else like d5 might be internally processed in the closest format for performance and in certain cases like casting, comparison, printf, or similar rounded to the specified number of base-10 decimal digits).

This scheme doesn't have the flaws as noted above, is intuitive, is fully multiplatform, is performant (even on 6 years old HW the d16 SW-emulated version takes only about 2.5 the time of f64 on HW FPU). SW emulation will not be needed on Intel, AMD, POWER, RISC-V CPUs (only ARM will require it AFAICS). Note, by SW emulation I mean V using the mpdecimal library instead of just compiling to C's decimal32/decimal64/decimal128 (I do not mean what the C compiler actually does under its hood).


Btw. NaN poisoning of approximate non-decimal floating-/fixed- point number representations is widely used for plausible deniability cracking and obfuscation (i.e. code which looks like an honest mistake even if discovered).

@dumblob
Copy link
Contributor

dumblob commented Sep 17, 2021

Apart from the default behavior, we could also look at the broader scope of "pluggability of real-like numbers" in V (maybe like https://github.com/stillwater-sc/universal for C++ where it's just one #include... to completely change the default) as discussed in vlang/vsl#65 (comment) .

This might give us an important insight what the ecosystem then could look like. And yeah, it looks much better than now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants