Comparable and FloatingPoint types

:+1:

2 Likes

This approach makes it wrong to ever use < or == on an instance of a generic parameter type unless it is constrained to FloatingPoint (or to some protocol that a FP type can’t possibly be made satisfy—of which there are almost none). That’s really bad for generic programming in Swift, and for the legitimacy of <, ==, Comparable, and Equatable.

4 Likes

I am only a casual user of floating point arithmetic, but I do have some thoughts.

In my own code, comparing with NaN is almost certainly an error and I would like to know about it. In my code, the most useful thing for Swift to do is to trap on a comparison with NaN. This can be justified both mathematically and practically.

  1. Mathematically, the result of comparing with NaN cannot be expressed as a boolean — it is neither true nor false.

  2. Practically, a comparison with NaN is likely a logic error. Maybe the programmer didn’t realize that a result he is comparing had the possibility of being NaN. It is better to learn about it right away than have the program quietly do what may be the wrong thing.

Chris Lattner says that the current behavior is not a show stopper. Perhaps by that he means that it doesn’t come up that often in everyday code, and that may be true. But it breaks a fundamental property of how programmers think about comparisons. I think it is worth fixing at some point, even if it is source-breaking. It would relieve a mental burden on programmers.

Of the existing code that would be affected by trapping, most often, I think, it would be because of a logic error. In other words, most affected code would end up being improved as a result of Swift now trapping on a comparison with NaN, at least after the programmer fixes his bug.

As to the examples Steve Canon gives of unexpected results from comparing with NaN, maybe we could provide new functions to handle NaNs in arrays. For example:

foo.sorted( treatingNanAs:-.infinity ) // Would place all NaNs at beginning, as though they were -infinity.
foo.containsNan() // Similar to .isNan() except applying to an array

These would help make it clear in the code that there is a possibility of the values being NaN.

It might also be a good idea to provide a <=> operator similar to what Lattner suggests that includes “undefined” as a possible result. This way, when there is a possibility of a NaN in a comparison, the code makes that possibility clear (when it checks for an undefined result).

This has been an interesting thread to read. Thanks to all.

I've been cycling back and forth trying to decide whether source compatibility is so important. I found a link showing floating-point operators in a whole bunch languages, and they basically all followed the IEEE conventions. So that was kind of significant for me.

On the other hand, there are still 2 points in favour of using a special operator for IEEE behaviour:

  • I always learned that comparing a floating point to a specific value (via ==) is almost always technically incorrect, so I would suppose the majority of people doing it are casual users who expect Equatable behaviour.

  • Plenty of languages have multiple kinds of comparison operators (with some being looser than others). For example, in the link above, Groovy == doesn't have IEEE-compliant behaviour at all; OCaml has two operators: nan = nan returns false, but nan == nan returns true, and Julia has nan == nan as false and nan === nan as true. I don't see it as such a big problem if it's well-documented and you can easily get the behaviour you want.

I think going with &== is the best we can hope for.

4 Likes

FWIW, I just did the small experiment of creating a floating point type that can't (or at least should never ;-) be nan.
It wasn't that complicated, and maybe I'm going to use it in some real projects to find its flaws and errors.

I'm pretty sure performance is one big flaw, but afaics, there is no way to interact with status and control registers... I guess adding this option would be fair tradeoff for those who need IEEE-compliance if we steal their operators ;-)

I also tried to simulate the "nan is nil" behavior with public func ??<T: FloatingPoint>(lhs: T, rhs: T) -> T - but Swift will always prefer the regular meaning, so you can't reuse the operator in that way.

Edit: Just in case anyone wants to try it out, or copy some boring forwarding operator-declarations:

2 Likes

Even if you could interact with them in Swift, that would give worse performance on every existing architecture; reading and modifying these registers is generally a "stop the world" serializing operation, while testing for NaN with comparison is just another floating-point operation.

In a model where there were no "normal" floating-point operations, you could simply enable the invalid trap ("unmask the floating-point exception"), but you'd still need to toggle it at any call that could potentially leave the Swift model, which is every call to code you don't control, which would still end up being prohibitive. In a model that has both "normal" floating-point and your custom types, you have to toggle it around every operation, which is even worse.

You'll also run into the fact that trapping on IEEE status flags is an optional feature of several ISAs (e.g. ARM), and not implemented on common cores.

Right, this approach would require a small amount of stdlib / compiler magic.

3 Likes

I actually can't tell if I'm serious about this or not, but: if we were to introduce &== etc, those should be the names for the total ordering, rather than changing the meaning of ==. This is precisely by analogy to arithmetic operators: Int.+ fails to conform to the group axioms, because it isn't closed, Int.min doesn't have an inverse, and isn't associative because of overflow traps. Int.&+ is the outlet that patches all of those holes and gives us a group. Float.== fails to conform to the axioms of Equatable, Float.&== would be the outlet that patches those holes.

1 Like

What are the benchmarking numbers?

Is this only relevant for calls to libm? libm is something we’re going to have to eventually replace anyway and for calls to bigger C functions I don’t see how the reasoning against touching these registers is any different from reasoning against generic abstraction cost between swift modules. The argument I always hear is that these boundary overheads are small relative to the work being done in the body of the foreign function.

I’m going to push back against this pretty strongly.

Arguments like this pop up on Swift Evolution from time to time, where people push for inconvenience in the name of consistency, and I think we need to refute and rebut that entire line of reasoning.

Consistency should be pursued insofar as it simplifies the mental model and furthers our design goals, namely clarity at the point of use and progressive disclosure of complexity. Calls for consistency which do not support those goals, or which in some way undermine them, should not be heeded.

In other words, consistency is not a goal in its own right, rather it is a tool that can, when used properly, advance our progress toward other goals. When used improperly, it can make things less convenient, less clear, and more difficult to reason about.

• • •

For the case at hand, the problem we are trying to solve is how floating-point types interact with the Equatable and Comparable protocols. Thus, if a consistency argument is to be made at all, it is the behavior of the operators defined by those protocols which should be made consistent, as that will benefit both clarity and progressive disclosure.

6 Likes

No, it’s not only for libm. In fact, libm is the only thing that doesn’t require it. The C standard (and hence most other language models that don’t specify anything but just defer to what C does) explicitly says that every function except for libm and a couple other exceptions must be called with the default floating-point environment in effect.

I disagree very strongly with this view. Ultimately, we are striving for a language that is usable and teachable, and consistency of the language stands independent of clarity at the point of use and progressive disclosure of complexity in reinforcing those goals.

2 Likes

Which part, exactly, do you disagree with?

The first paragraph, which says that sometimes people push for inconvenient consistency?

The second paragraph, which says that consistency should only be used to support Swift’s design goals?

The third paragraph, which says that consistency for its own sake isn’t always helpful?

I agree that consistency stands independent of clarity, and that is precisely why we must take care to ensure that the pursuit of consistency does not adversely impact the language.

Consistency is and should remain subservient to the greater purposes of usability and teachability. After all, if we follow a vision of consistency to a place of impaired clarity, then the result will be detrimental.

1 Like

I disagree with the first paragraph insofar as it aims to rebut any move to push for consistency if it’s inconvenient.

I disagree with your characterization in the second paragraph of Swift’s design goals, and in particular that consistency of the language should be subservient to progressive disclosure and clarity at the point of use rather than a co-equal tentpole that promotes usability and teachability.

I disagree with your third paragraph in the sense that I argue that consistency, per se, is a virtue for a programming language. In particular, inconsistency of a language in the form of too many special-case or seemingly arbitrary rules is a valid critique of a language, even if each of those rules can be said to provide some convenience.

1 Like

I think it is pretty reasonable to interpret the semantic requirements of Equatable and Comparable as implying totality. Comparable even explicitly requires a total order. Allowing a conformance to make these operations partial functions that trap doesn't feel like a good solution to me. Nor does providing these operators on concrete types with very similar, but also significantly different semantics even if a conformance is not provided.

The problem is that if floating point types conform to Comparable the generic sorted would still be available. You're not suggesting removing the conformance are you?

Also worth keeping in mind is that this is not strictly about use of == on concrete floating point values. The heart of the issue IMO is whether floating point types should be allowed to conform to Equatable and Comparable without meeting their semantic requirements. I don't believe they should. I also don't believe they should exhibit different behavior for the same operator in concrete and generic contexts. Yet these conformances are extremely desirable for a number of reasons. That is the reason I concluded that using the & prefix for IEEE semantics is the best long-term direction.

I think I see the rationale here but I'm not sure it works given that the spelling of Equatable.== is not going to change. This rationale only seems to make sense from a numerics-specific perspective while Equatable is much more general.

Also to clarify: are you suggesting that floating point &== would provide the implementation of Equatable.== for floating point types? Doing that but keeping == on concrete floating point types with IEEE semantics seems like a recipe for confusion to me.

3 Likes

How expensive would this really be in practice? I would think the argument that the boundary cost is small relative to the cost of executing the C function itself in most cases, which is the same argument we use for generic swift-to-swift module interfaces, would apply here,, and unlike generic abstraction, the cost of changing the floating point environment would only be incurred on entry and exit from the C function.

Also say if i’m wrong here but doesn’t calling C functions already have some overhead compared with calling a swift function, what with the different calling convention and whatnot.

The cost is greater than generic abstraction, and a couple orders of magnitude worse than a normal call.. In carefully thought out code it’s manageable, but it would be a significant footgun.

Also, I should note that without this concern, C calls have no overhead; the different calling conventions are not any issue because Swift understands them natively, so they don’t need a shim; swiftc will even inline C calls where appropriate.

6 Likes

Floating points definitely can conform to those protocols, just not with IEEE semantics (the definitions are in conflict). The heart of the issue really is just about how you spell each variation: everybody agrees we should have both.

  • On the one hand, it could be a kind of operator overload which is resolved by the generic environment.

    For: Best for source compatibility
    Against: Could be confusing if operators work differently in concrete/generic code, requires new Equatable.== syntax

    This is how it would look in practice (let's say I want to make sure my comparison is consistent with what Sequence.min() uses):

    var numbers = [1.0, 1.0, 2.0, 3.0, 5.0, 8.0]
    var smallest = numbers.min() // uses Comparable.<(Double, Double)->Bool
    
    if let nextNumber = getAFloat(), 
           Comparable.<(nextNumber, smallest) { // < this syntax doesn't exist.
      smallest = nextNumber
      // ...
    }
    
  • Alternatively, we could introduce a new set of operators

    For: semantics align better with other Swift Equatable/Comparable types, behaviour difference is more explicit and easier to reason about/document/teach

    Against: source-breaking

    We might be able to detect some obvious situations like if x == x and offer fix-its to convert to the IEEE operator or an isNan check.

1 Like

It's a playground, so I'm still waiting for the execution to finish ;-)

I didn't expect good results, but I believe Steve when he says that it's possible to tweak the performance to nearly match conventional math.
The show-stopper might not be the IEEE-handling of nan, but how you can generate infinite results:
As I stated before, nan != nan is fine for me (just not for Equatable ;-), but when adding two regular numbers can produce infinity, that's at least extremely questionable...
Therefor, I included bounds checks, which should ensure that division is the only "dangerous" operation, and that you can perform +, - and * without introducing Optionals.

The Debug-build of a simple sqrt-implementation performs really terrible

Native: 0.11754989624023438s; result = 333333333.6157117
New: 6.494667053222656s; result = SDouble (333333333.6157117)
Slowdown: 55.2503001784845

whereas release is just slow ;-)

Native: 0.025515079498291016s; result = 333333333.6157117
New: 0.08780801296234131s; result = SDouble (333333333.6157117)
Slowdown: 3.4414163972415857

Beware, if the operators +, -, * and / are changed, so should perhaps math functions like log which returns NaN when the argument is negative,

1 Like

foo.sorted( treatingNanAs:-.infinity ) // Would place all NaNs at beginning, as though they were -infinity.
foo.containsNan() // Similar to .isNan() except applying to an array

These would help make it clear in the code that there is a possibility of the values being NaN.

The problem is that if floating point types conform to Comparable the generic sorted would still be available.

That’s right, you could still call sorted() on an array of floats. It’s just that if that array contained any NaNs, the operation would trap. This makes sense because NaN doesn’t have a natural order. In many cases, the trap will reveal a logic error. If you know the array you're sorting could have some NaNs in it, you use something like “foo.sorted( treatingNanAs:-.infinity )”. This has the advantage of making it clear in your code that NaN is a possibility and explicitly saying how you want NaN to be treated.

The idea behind trapping on comparisons with NaN is similar to trapping on interger overflow. The result of an integer overflow cannot be represented in the result type. Rather than quietly ignoring what may be an error in logic, Swift traps. The programmer either fixes the error or makes explicit how the situation is to be treated.

Likewise, a comparison with NaN cannot be represented in a boolean. Rather than quietly ignoring what may be an error in logic, Swift should trap. Then the programmer either fixes the error or makes explicit how the situation is to be treated.

Terms of Service

Privacy Policy

Cookie Policy