apple / swift-numerics

Advanced mathematical types and functions for Swift

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Usage of "unsafe" does not match Swift’s standards

NevinBR opened this issue · comments

In the standard library, Swift uses “unsafe” to denote operations that are memory-unsafe. This sets an expectation which I think we should respect and follow.

Currently, Complex has a member named unsafeLengthSquared. This operation is memory-safe, so in accordance with Swift’s established precedent it should not have “unsafe” in its name.

I propose renaming it to lengthSquared, and documenting that the calculation could overflow (or underflow).

We can call it naiveLengthSquared or uncheckedLengthSquared, but I do also think it’s also fine not to prefix it with anything as the square of a value is pretty self-evidently going to have the possibility of overflowing.

I will say, though, that if magnitude is going to be used for the infinity norm, we might as well be precise and name these things euclideanNorm, euclideanNormSquared, etc.

There’s not yet any established precedent for naming numerical operations that produce useless results for poorly-scaled inputs, but which are sometimes needed for performance reasons. We’re effectively creating it.

It’s reasonable to argue that we don’t need “unsafe”. I think using “lengthSquared” alone is a mistake—we want a uniform prefix that indicates a fast algorithm that does not correctly handle poorly scaled data, and that prefix should be neither the empty string nor something like “fast”. I’m open to something other than “unsafe”, though.

...we want a uniform prefix that indicates a fast algorithm that does not correctly handle poorly scaled data, and that prefix should be neither the empty string nor something like “fast”. I’m open to something other than “unsafe”, though.

Small suggestion: the standard library uses underestimated (like underestimatedCount) in a handful of places.

Underestimated isn’t semantically correct in general, unfortunately.

Am I correct that x*x + y*y will always be within one ulp of the closest representable value to the actual length squared?

A 1-ulp error bound is not totally obvious. Without thinking very hard because it's Sunday evening, the actual bound might be something like 1.25 ulp, but it's definitely quite small.

I think we should change it to a throwing function:

public var unsafeLengthSquared: RealType {
    x*x + y*y
  }

It is not constant time performance on this computed property, something that is encouraged for computed properties.

Since multiplication is fast but not constant time and since this thread is debating naming, why not:
func lengthSquared() throws -> RealType?

It is constant time for any fixed-width floating-point type, which is 99.9999% of use for this operation (that's an under-estimate). For the remaining .0001% of uses, it is fast enough that the difference is not observable except in the finest microbenchmark. Having it be a computed property is correct.

This API already has a means to indicate failure (zero or infinity is returned)--so throwing adds some small overhead for no additional benefit. Here's what a typical "careful" use case looks like (from divide):

let lengthSquared = w.unsafeLengthSquared
guard lengthSquared.isNormal else { return rescaledDivide(z, w) }
return z * (w.conjugate.divided(by: lengthSquared))

Making it throw instead wouldn't really simplify this at all. Making it optional would simplify it, just the tiniest bit:

guard let lengthSquared = w.unsafeLengthSquared else {
  return rescaledDivide(z, w)
}
return z * (w.conjugate.divided(by: lengthSquared))

However, the primary use of an API like this in contexts where the programmer can simply assume that their data will be well scaled (like many graphics workloads), so there's no "fallback" path. In those contexts, affordances for the "failure" path just add noise. If it's an optional, most uses will just force unwrap it. If it throws, most uses end up decorated with try blocks with no meaningful error recovery path. This use pattern is also why unsafe is somewhat appropriate--in typical usage, if the data is out of range, who knows what happens? It's not undefined, but it's likely to either be unspecified or not to matter to the broader algorithm.

A 1-ulp error bound is not totally obvious.

I believe it is even stronger than I originally wrote. The calculated result x*x + y*y and the closest representable number to the exact value will always be either equal or adjacent.

This should be true in every case, in every radix, even at boundaries between orders of magnitude (ie. when the significand is one), even when one of them is infinite or zero, and regardless of the rounding mode.

• • •

Given that length-squared is a convenience property that just provides a handy name for a common calculation, and given that the result is extremely accurate, I think it is totally fine—in fact desirable—to give it a simple name and let the documentation describe its behavior.

@stephentyrone Well if one is working with military-grade code, the 0.0001% might be relevant? :) Maybe?

You gave a good example of usage of Optional, however that example applies to any throwing function too, since any throwing function can always be reduced to a function returning an optional by the use of try?, e.g (using your example):

// We do not care about the error
guard let lengthSquared = try? w.lengthSquared else {
  return rescaledDivide(z, w)
}
return z * (w.conjugate.divided(by: lengthSquared))

But in the scenario where one cares about the error, one can still make the call using just try.

My idea of switching over to a throwing function instead of a computed properties was mostly due to the nice context of something being "unsafe", you kind of get for free when the function is throwing, allowing you to (potentially) drop any prefixes such as "unsafe", it could then just be func lengthSquared() throws, or possible func squaringLength.

This is not anything I feel strongly about, I was just trying to contribute to the conversation :)

A 1-ulp error bound is not totally obvious.

I believe it is even stronger than I originally wrote. The calculated result x*x + y*y and the closest representable number to the exact value will always be either equal or adjacent.

What you suggest here is weaker than a 1-ulp bound--it would permit errors of 1.5 ulp. And I can trivially produce examples that violate your hypothesized 1-ulp bound, so that's good (an exhaustive search is feasible for Float but would take a while to run to completion, but I got to just shy of 1.125 ulp as soon as I started looking.)

let x: Float = 0x1.000ffep-1
let y: Float = 0x1.0e7e96p+0
let naive = x*x + y*y // 0x1.5dd74p+0

This is 1.1249 ulp smaller than the exact result, 0x1.5dd7423fefe... (this case does satisfy your follow-up conjecture, which I believe may be true, but haven't proved).

@stephentyrone Well if one is working with military-grade code, the 0.0001% might be relevant?

That's one domain in which it is definitely not relevant. Military applications are concerned with maximizing efficiency (usually computation per unit of heat dissipated subject to a volume constraint), which necessarily means a fixed-width type =)

What you suggest here is weaker than a 1-ulp bound--it would permit errors of almost 2 ulp.

I do not understand. Could you please provide an example where two adjacent non-negative representable values differ by more than 1 ulp for either of them?

And I can trivially produce examples that violate your hypothesized 1-ulp bound, so that's good (an exhaustive search is feasible for Float but would take a while to run to completion, but I got to just shy of 1.125 ulp as soon as I started looking.)

I do not understand. Could you please provide an example?

I do not understand. Could you please provide an example where two adjacent non-negative representable values differ by more than 1 ulp for either of them?

Your misunderstanding is that you are thinking of error bounds as being measured between a computed value and "the closest representable number to the exact value". Errors are never measured this way. They are measured between the computed value and the exact value (without rounding).

I already gave an example upthread, but here's a case that almost touches my hypothesized 1.25 ulp bound from yesterday:

x: Float = 0x1.0045bep+0
y: Float = 0x1.791364p+0
exact x*x + y*y: 0x1.95fad67ffc...p+1
computed x*x + y*y: 0x1.95fad4p+1

This is an error of about 1.24997 ulp.

Your misunderstanding is that you are thinking of error bounds as being measured between a computed value and "the closest representable number to the exact value". Errors are never measured this way. They are measured between the computed value and the exact value (without rounding).

I have never used the term “error bound” in this thread. I have not been talking about error bounds.

I have been talking about the difference between the “best” representable number for the squared length, and the actual result of the floating-point calculation x*x + y*y. I specifically wrote that I was asking about that difference, every single time I mentioned it.

I believe that I am fully correct in all my assessments about the difference between those two representable floating-point numbers (where “best” means applying the floating-point rounding rule to the exact arbitrary-precision result).

And the reason I have been talking about that difference, is to quantify how far off the value returned by unsafeLengthSquared is from the value that would be returned by a more robust correctlyRoundedLengthSquared algorithm.

My conclusion is that the two return values would either be the same as each other, or they would be adjacent representable values. Thus, since they are both non-negative, it follows that they are each within one ulp of the other.

Ok, sure. What's your point? What does this have to do with anything?

The question here is emphatically not about what to name an algorithm with an over-large error bound; the question is about what to name an algorithm that:

  • returns a good result if the input data is well-scaled
  • otherwise returns only an indication that the input data was poorly-scaled, in the form of an obviously meaningless result (0 or inf)

The accuracy of the good result has never been in question, so I'm not sure how it would effect the meaning. The distinction I'm attempting to draw has never been between unsafeLengthSquared and a hypothetical correctlyRoundedLengthSquared; that would be an almost useless API, since it would do a bunch of work and still not handle poorly-scaled data correctly.

The distinction I'm attempting to draw has never been between unsafeLengthSquared and a hypothetical correctlyRoundedLengthSquared; that would be an almost useless API, since it would do a bunch of work and still not handle poorly-scaled data correctly.

Oh. I thought you were suggesting that you didn’t want to name the existing property lengthSquared because you thought that name should only be used if the result were correctly rounded.

If that is not your objection, then I feel extremely strongly that the existing property should be renamed to lengthSquared. Since you are certain that we will never want a correctly-rounded version, there is no competition for that name.

The calculation is indeed extremely accurate, hence the name lengthSquared is also extremely accurate for describing what it does.

The fact that lengthSquared can be much smaller or much larger than the input, is a direct consequence of what squaring means. It is easily understood, and there is no need to make the name longer to call out something that is already explained by the rest of the name.

As long as the documentation is clear about what the property does, and how to use it properly, there is no problem.

I thought you were suggesting that you didn’t want to name the existing property lengthSquared because you thought that name should only be used if the result were correctly rounded.

I don't believe that I ever wrote anything to suggest this; apologies if I created that impression, but it would be entirely contrary to almost every other API in Complex, which are mostly also not correctly rounded, but not prefixed with "unsafe".

I should note that there's a 40+ year precedent for using "safe" in this sense that I mean it here in numerical computing; see, e.g. "safe minimum" / SAFMIN in LAPACK for the smallest value for which a division can be safely replaced with a multiplication. I'm not completely opposed to dropping unsafe here, but neither is this an unusual usage; I'll discuss this with some of the Apple standard library team members today and see how they feel about it as well.

@natecook1000, in a slack, nails how to follow numerics tradition and avoid stomping swift usage: "Okay, you can call this unsafLengthSquared" 😂

I'm going to rename this property lengthSquared. This will land together with some documentation work early next week (WIP here: https://github.com/stephentyrone/swift-numerics/tree/lengthSquared). unsafeLengthSquared will be marked unavailable and renamed.

Nevin, please close unless you think further changes are needed.