High Performance Correctly Rounded Math Libraries for 32-Bit Floating Point
blog.sigplan.org
blog.sigplan.org
This is really nice, not necessarily because anyone cares about the correctness of the last bit in a float. But more importantly, because since there is only one "correct" answer, requiring correct rounding means that those functions are then fully specified and deterministic. No more getting slightly different result when changing platform, OS, or even just updating libm (some transcendental functions are still actively being improved, thus changed, in glibc!). This is amazing for reproducibility.
Since the performance is good (even better than existing libm implementations, they claim), this is a true win-win.
This is obviously made possible by the fact that one can easily enumerate all 32-bit floats. It would be amazing to have something similar, even at a big performance cost, for 64-bit doubles (MPFR can do it of course but the perf cost there is truly massive). Things are much harder with 64 bits, unfortunately.
Optimizing compilers are usually used with flags that cause them to break IEEE754.
Still, it would be nice if results from low-optimization runs might be bit-exact.
However, as far as I understand, specifying a proper language standard (for example -std=c99, -std=c++14, etc.) restores sane behavior (see -ffast-math, and more specifically -fexcess-precision).
Are there other cases where -O3 breaks IEEE754 on modern setups?
The dark underside of IEEE 754 is that full support for it also requires features that most languages don't provide access for: rounding mode and exception handling (aka sticky bits). In C/C++, using these features correctly requires STDC FENV_ACCESS support which is only in ICC and recent clang (neither GCC nor MSVC support it). And there's usually two hardware bits for handling denormals (flush-to-zero and denormals-are-zero) that aren't in IEEE 754, which tend to be on by default when you're compiling for some targets (most notably GPUs).
[1] https://software.intel.com/content/www/us/en/develop/documen... -- note fp-model=fast=[1|2]
E.g.:
void foo(int N) {
for (int i = 0; i < N; i++) {
#pragma STDC FENV_ACCESS ON
// This applies only to the for loop...
}
// But not here, for example!
}This is not always true. Some FMA operations round after the multiply, and some do not.
For languages that don't mandate IEEE-754. There are plenty of languages that do, like Java, JavaScript, C#, and WebAssembly.
It's worth noting that usually such optimizations are things like enabling algebraic reassociation and commutation (of primitives like +, -, /, ==, etc) operations, fused-multiply add, and others. It's really not clear to me how much disabling all those optimization costs in the real world.
Things get crazy when compilers want to do major reorganization of loops, optimizing stencils, polyhedral optimizations. I am not an expert here, but I think these rely on properties of float arithmetic that don't hold in all cases, i.e. they rely on UB.
I assure you that SPECcpu scores are a lot lower, if that's what you care about. And yes, it's extremely helpful to do major reorganization of loops if the program has multi-dimensional loops in the wrong order.
And yes, performance wasn’t good, especially compared to the x87 that IBM PCs used. It did claim to round the last bit correctly, though, where early x87s could have large errors (https://randomascii.wordpress.com/2014/10/09/intel-underesti...)
Solved and published over 15 years ago. The underlying methods were published over 20 years ago (V. Lef`evre, J.M. Muller, and A. Tisserand. Towards correctly rounded transcendentals. and Lefvre's associated PhD thesis).
https://hal-ens-lyon.archives-ouvertes.fr/ensl-01529804/file...
The Correctly Rounded Math Library (CRLIBM) is available for several languages.
Single precision floating point is a small enough domain that it can be fully enumerated--you can prove that everything rounds correctly and find the problematic cases. 24-bits just isn't that large to modern computers.
Double precision floating point is still too large to fully enumerate. So, there could be an exception lurking in there that goes out a long way.
In reality, it seems that rounding requires about 2n + log2 n bits and never 3n bits (I don't think we've ever found an exception to this). However, if you can prove that, you should submit it as it would be a significant advance in the world of mathematics.
So, the problem isn't bounded by simple probabilities. There are other properties involved that cause it to diverge from the "naive" probabilistic estimates. What those properties are we don't know yet.
The "rounding" problem is called the "Table Maker's Dilemma" and it descends into some pretty fundamental transcendental number theory.
This is not an "easy" problem. Proof that PI was transcendental is relatively modern (1885).
In my experience, low order bits of transcendental functions make terrible rng sources (even discounting their runtime). Your example of the logarithm is great -- for large inputs, you need large changes to flip even low order bits.
Elkies [0] computes a piecewise linear approximation to his (number-theoretic) function of interest and does a lattice reduction on each piece to filter out the vast majority of the space. The same approach, works almost everywhere on all of the transcendental functions I've tried, and I believe the CRlibm group uses it.
I converted a satellite orbital prediction program that used doubles (64-bit floats) to use long doubles (80-bit floats) but only saw a difference in the 11th decimal place, with lots of sin/cos/atan/sqrt involved in the calculations. With 32-bit floats it was a joke: the accumulated error made the 'predictions' useless.
The basic problem with 32-bit is you have to be quite careful with your arithmetic so you don't lose significance. It's almost as bad as using Q-notation. https://en.wikipedia.org/wiki/Q_(number_format)
This is a very important use, though!
The exception is cancellation error, which can suddenly cause the error rate to spike dramatically. But if you sort your numbers before adding/subtracting, you can often negate this overwhelming cancellation error.
The exponential growth of other floating point errors is almost unstoppable however. The only real way to stop it is to design an algorithm that operates with fewer steps.
---------
64-bit has a much smaller initial error (1/2^53), while 32-bit has (1/2^23) initial errors... starting with 30-magnitudes more error than double-precision.
EDIT: Apparently there's a nice Rust crate for doing this stuff: https://crates.io/crates/accurate
[1] https://en.wikipedia.org/wiki/Kahan_summation_algorithm#Alte...
For scientific computing? Generally 64 bit floats give you the needed precision. But if you're chaining a lot of operations that exacerbate floating point error you may need more.
For usage in trading? Again, depends on what computations you're doing. There's no "hard and fast" rule.
I was googling this recently, when deciding whether to use floats or doubles in a simple game I was writing. Some answers suggested that (32-bit) floats aren't necessarily any faster in real-world applications on modern hardware. If you or anyone else feels like elaborating on this I'd be interested.
For anything that is vectorizable or otherwise amenable to SIMD optimizations you can get up to 2x speedup.
Outside raw computation rate though there is also the matter of cache and memory bandwidth. You'll use 2x with doubles which can really bog things down as even if the CPU can crunch the calculation quickly it could spend a lot of time waiting for the data to crunch to arrive.
All that being said if you know your game is going to be simple then it probably doesn't matter one way or the other even if it does come at a big performance cost.
Instead, games tend to take some input parameters, use those to transform a read-only data set, render a frame, and then throw everything away. The next frame starts from scratch. There's no accumulation.
In my experience, typically only the "player camera angle" is kept around and accumulated into. The precision errors here are negligible. Nobody cares if the viewport is 0.00001 degrees to the left or right of where it "should" be.
Most other parameters that are kept frame-to-frame tend to be "world simulation" stuff. In that case, consistency is so important for network codes that most games use fixed point or integers only. This avoids any kind of discrepancies when using delta compression or multiplayer where people have different CPUs.
This is what Kahan has to say on the matter:
For now the 10-byte Extended format is a tolerable compromise between the value of extra-precise arithmetic and the price of implementing it to run fast; very soon two more bytes of precision will become tolerable, and ultimately a 16-byte format ... That kind of gradual evolution towards wider precision was already in view when IEEE Standard 754 for Floating-Point Arithmetic was framed
(The quote is apparently from 1994 unpublished manuscript, so I can't find the source for it)
More than 25 years later and we are rarely using even 80 bit doubles, nevermind 128 bit ones.
I ended up creating a set of values that follows different mathematical functions in different segments (exponential, linear, quadratic). As a float this wouldn´t have worked, because the precision halves for every new exponent, so the distances between adjacent values are not approximately linear.
Historically, high-performance libm implementations have gone to great pains in their internal benchmarking projects to avoid accidentally ignoring these effects and shipping functions that are significantly slower in real use. None of this is to say that they have necessarily fallen into this trap, but historically most "fast" math function implementations that use piecewise polynomials with high segment counts do (and none of it should take away from the accomplishment--even if they're really 2x slower than claimed, they're fast enough for most code to simply use it unconditionally and get the portability benefits).
Nowadays that's not an issue, as there's a slew of different ways of overcoming that and allowing clients to operate asynchronously; sometimes widely asynchronous.
Also search for "game networking" + the following:
- state replication
- dead reckoning
- client side prediction
- networked physicsHowever:
1. As the paper emphasizes, math library functions (sin, cos, log, etc.) are not covered by that standardization, at least not mandatorily and not in practice. This is the problem that the paper tackles.
2. In the bad old days of the x87, the FPU carried out computations on eight 80-bit registers. The problem is that whenever those registers spilled out to the stack, precision was truncated down to whatever type the language actually meant (64-bit double or 32-bit float). Because the register-spilling decision is mostly taken by the compiler, it was largely unpredictable. This resulted in code that was not reproducible, even across compiles! Since x87 was deprecated in favor of SSE/AVX, the precision of operations is always that of the type used in the code. Lower than 80-bits but much better in practice because of consistency and reproducibility.
3. The bad old days are making a comeback with FMA instructions (fused multiply-add). This is a single operation (a x b + c) and so it is correctly rounded to the closest representable number. This number can be different from ((a x b) + c) in which two roundings occur. If we give the compiler freedom to decide whether or not to fuse such expressions, we will again see non-reproducibility across compiles. Unfortunately, gcc and icc both do that by default at -O3 (see -ffast-math, and more specifically -fexcess-precision). Specifying a proper language standard (for example -std=c99, -std=c++14, etc.) restores sane behavior.
I think Julia takes an interesting approach here of never reordering floating point computations unless you explecitly give permission using @fastmath (which acts locally instead of globally like a --fast-math in C/C++). This makes it much easier to use ieee specific tricks and be confident that the compiler won't mess with you, while still making it easy to tell the compiler to do what it wants.
True, and the small performance boost (on some platforms) is nice too
> never reordering floating point computations unless you explecitly give permission
Yes, requiring explicit code for FMA and arithmetic grouping/reordering in general seems like the sane approach.
WebAssembly as well. IEEE-754 is mandated and there are no unsafe compiler optimizations allowed. This is the only sane choice. It's just too hard to reason about programs otherwise. Nothing about a super-intelligent machine completely reorganizing your code in a way that subtly breaks it is good, IMHO.
even today it's still possible to end up with things in the x87 registers, I don't know of a compiler toggle to entirely and unequivocally disable it.
This can be triggered unintentionally. For example, string-to-double conversions in Boost use long doubles.
(I have been bitten by that when using valgrind: Valgrind emulates x87 80-bit registers using 64-bit doubles. As a result, some Boost-using code will have a different behavior under valgrind.)
Notice in this article, in the section entitled "What is the correct result of f(x)?" they say "e.g. ln(x)" in one case and "i.e. ln(x)" three sentences later. Well which is it? It is not just that I am a curmudgeon. Precision matters. When they say, for example, "Everyone uses math libraries (i.e. libm)" do they mean "Everyone uses the libm math library" or, more likely, "Everyone uses math libraries such as libm". Yes, the resolution in this case is fairly obvious, but I would get much more out of the article if I was not distracted by these slight inadvertent ambiguities and misuses.
It hadn’t occurred to me that this is unusual - I suppose it is? I wonder if it’s a aus / US cultural difference? (I’m Australian) or maybe something I picked up years ago as a grad student?
https://gforge.inria.fr/scm/browser.php?group_id=5929&extra=...
Github mirror: https://github.com/taschini/crlibm
Python bindings: https://pypi.org/project/crlibm/
Julia bindings: https://juliapackages.com/p/crlibm
Whats this then?
https://github.com/rutgers-apl/rlibm-32/tree/main/source/flo...