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:
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 languagef64 multiplications are somewhat expensivef64 can be reduced with tolerant checks others become worse _(numbers that should differ seen to be equal)_.<, == and > should evaluate to trueFor these reasons I'd like to propose using standard equality checks in the v core language. Any thoughts?
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.
@UweKrueger I agree.
@UweKrueger I agree too :+1:
@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 :wink:.
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.5above 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 thevcore 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.
f64multiplications 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
f64can 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 totrue
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?
@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.
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 Thanks for your detailed reply. I think it goes without saying that I disagree... ;-)
:wink:
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
vcompiler cannot have the wisdom to make the correct choice. Forvas 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 falseThe 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:
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.
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).
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 :wink:. But that's an obvious nonsense, so let's stick to pragmatism & simplicity and treat operator comparisons as tolerance equality check.
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 :wink:).
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):
<= 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.Fourth, these are some languages having built-in floating point equality well-defined (unlike many which ignore all the above issues).
⎕CT of 1e-13 for 32bit floating point and 3e-15 for 64bit floating point - i.e. absolute tolerance, both changeable)-1..1 interval and not counting subnormals as zero)== (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).=~= (fixed epsilon of 1e-15, changeable)[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 :cry:).
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 }):
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).
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.
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).
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.
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).
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
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.
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").
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.
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.
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 :cry:.
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).
Most helpful comment
Just to make a note:
First we must consider the conversion from string to float
a := 12.234567890123456in 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.