> Everyone already knows floating-point operations are imprecise and it wouldn’t be fun to blog about.
Wellll... Yes and no. And I want to nitpick "FP ops are imprecise" because it's important sometimes.
It does come up in Lua - Lua uses 64-bit double floats for everything, _even array indexing_, because they have 53 bits of mantissa and they're guaranteed to represent all 32-bit integers with 100% precision.
I just opened Lua and got `2 ^ 32 == 4294967296.0` and `2 ^ 32 + 1 == 4294967297.0`. You can store 4 billion things in a Lua table and access them with double float indexes.
The usual "0.1 + 0.2 != 0.3" is _not_ imprecision. You could dedicate 1,000 bits to a float and still find some example of a number that's trivial to represent in decimal but repeats forever in binary.
While I'm on it, fixed-point and floating-point aren't magically different. Floats work better on very big values. Fixed-points are more predictable but run out of range easily when squaring numbers. This comes in 3D math when finding the length of vectors or normalizing vectors.
The PlayStation 1 didn't have jiggly vertices and warpy textures because of fixed-point. It had jiggly vertices because its GPU didn't have subpixel-precise rendering, and it only had 16 bits of precision. It had warpy textures because it had affine texture mapping. The N64's GPU had more bits of precision and it had perspective-correct texture mapping. There's no 16-bit floating-point format that would have saved the PS1 from wobbling.
Also floats aren't the cause of T-junctions or "sparklies" that are common in 3D game levels. That will happen in any system with finite precision, and if fixed-point would fix it, we'd use fixed-point.
Thank you! I completely agree with this, floats are treated as a lot more magical than they actually are. I myself often rely on their exact guarantees for fun tricks (e.g. fast precise int<->float conversion: https://purplesyringa.moe/blog/fast-limited-range-conversion...). But while IEEE-754 is very precise, some subtleties arise when you add library functions to the mix -- many libm's and userland libraries don't guarantee 0.5 ulp precision for certain operations and don't document the guaranteed precision either, at which point you're left guessing and saying "well, I guess I should treat floats as magic in this case after all". I added "it's not imprecision" to step around this whole question.
There's a widespread misconception (not shared by the article author) that floating point arithmetic is "imprecise" in the sense that the result is off by some amount of random noise. On the contrary, IEEE floating point results are precisely specified to produce the closest representable value to the mathematically exact result.
For efficiency reasons, library functions (and sometimes, sadly, hardware implementations) don't always guarantee this (closest representable) exact result. That's fine if the function is then called "approximate_inverse_square_root" or something. Sometimes being off by some epsilon is a good tradeoff for 10x efficiency. I'm unhappy when such a tradeoff is smuggled into my math library without warning.
I checked Rust's source code to confirm the article's claim that it doesn't have a precise log function, and indeed, here's the implementation:
I'm sure other math libraries aren't better. But this is not a correct implementation of arbitrary-base logarithm. A function like that perhaps shouldn't even be offered in the standard library at all (since it's so trivial to begin with), or at the very least, not with that name. If a programmer wants to opt-in to a fast but slightly wrong value, they should do so explicitly, in my opinion.
Thank you! This point has been driving me mad for the last couple of years.
While answering a sibling comment, I found a paper analyzing various libms for their precision: https://homepages.loria.fr/pzimmermann/papers/glibc238-20230... (2023). I only skimmed it, but it looks like only LLVM's libm guarantees 0.5 ulp for every supported single-precision operation, and everyone else is wildly off. That's a pretty good result, though -- it means there's at least one reasonably compliant libm :)
Another sibling comments says that guaranteeing 0.5 ulp for double-precision operations is nigh impossible (and then another says it is after all). I don't have the knowledge to confirm which is true, but it's possible that this is the best we can get.
> On the contrary, IEEE floating point results are precisely specified to produce the closest representable value to the mathematically exact result.
Not sure about Rust, but C++ only guarantees that for four arithmetic operations and `sqrt`. Not `pow`, not `log`. Not even when when all inputs/outputs are integers. Sometimes you can even get `(int)pow(10, 2) == 99`, which I believe is fully standard-compliant.
Yeah, those operations (+fma) are the only ones for which IEEE 754 mandates correct rounding.
Calculating correct rounding for other functions without hardware support is both challenging to implement and is often really slow. And that's before one gets into special functions. Or, even worse, inverses of special functions. Sometimes you might be lucky when those don't over/underflow around the edges of the domain
Recently I was reading an article on the EML operator (exp-min-log) that uses exponentiation and logarithm to build elementary math functions including arithmetic operations. There was a table of results from testing across languages.
Language Result of 2 x 3 Error
---
Node.js v25.3.0 6.000000000000000 0
Python 3.9.6 6.000000000000001 8.88e-16
PHP 8.5.1 6.000000000000001 8.88e-16
Go 1.26.2 6.000000000000000 0
Rust 6.000000000000001 8.88e-16
It was speculated that this miniscule margin of error, 1 ULP (unit in the last place), is likely due to the difference in how log() is implemented by the language. Supposedly Python, PHP, and Rust use LLVM's libm (C math library) but maybe Go and Node.js internally compile to CPU instructions directly? There was no evidence presented, so I was skeptical of this explanation.
Python, PHP, and Rust do indeed use libm. Not LLVM's libm specifically, though, but rather just about anything they can find in runtime, and those libraries can have suboptimal accuracy.
Node uses V8, which notably implements a ton of math manually, because it needs the results to be deterministic across devices. It seemingly uses LLVM's libm, which IIRC promises 0.5 ulp for most operations. (https://github.com/v8/v8/blob/f24c62fbc342d616032734b714116e...)
Go avoids dynamic linking, so they also have their own implementation. (https://github.com/golang/go/blob/543ead71a8e7acc2bd6f326327...) They only promise 1 ulp, but I guess in this specific case it works out better than approximations used by the default libm on their system by pure chance?
Nice, thanks for tracking down those links, fascinating. Apparently being off by 1 ULP is within IEEE 754 floating-point spec, and "effectively negligible".
Correctly rounding transcendental functions is very difficult because of something called the table maker's dilemma, so the standard didn't want to impose a potentially extreme performance cost if they didn't have to.
Correctly rounded single precision functions at a reasonable cost are much easier these days. LLVM 18+ implements them, but the gnu libraries have been slower to improve.
This is the best source I could find, funny enough the author doesn't explain the name "table maker".
I suppose it's from the parable of a table maker who finds that one leg is too long, so they sand down that leg, only to find that another leg is too long, so they sand that down... slowly sanding away all the legs.
As a simple programmer I don't understand the dilemma. I do understand that sin, tan, sqrt, etc., take one argument, so you can guarantee a certain precision for them, at least in 32-bit floats, whereas a log in arbitrary base has two inputs, so its input space is huge and it's hard to guarantee anything, especially for 64-bit doubles.
If someone could write up a good explanation that could be its own HN post
> the author doesn't explain the name "table maker".
I suppose that parable fits, but also there is the dilema of someone making a table of (results) of transcendental functions. In the before times, most people got their sines and cosines and logs and what not by looking them up in tables. And someone has to make those tables. The example given where rounding to some specific number of digits depends on how many digits you calculate to is deeply unsatisfying, especially if you wanted to make a table for other people to use to lookup the results of functions.
The dilemma is hypothetical (because we now know the exact precision requirement for those functions over all 64-bit doubles) but let's assume that we don't know that and also we happen to use decimals instead. What if, some f(x) is known to be, say, 123.4999...9997 with 1,000 fractional digits? You have to calculate at least 1,000 decimal digits of f(x) in order to correctly round, before that we only know it's between 123.4999...999 and 123.5000...001. There seems no mathematical theory that specially prevents this annoying possibility, hence the dilemma.
> CORE-MATH Mission: provide on-the-shelf high performance open-source mathematical functions with correct rounding that can be integrated into current mathematical libraries (GNU libc, Intel Math Library, AMD Libm, Newlib, OpenLibm, Musl, Apple Libm, llvm-libc, Microsoft libm, CUDA libm, ROCm)
That's beautiful. Here's the Git repo with implementation.
The original article [^1] didn't specify the CPU, but I gathered the code examples (and added one for Lua, untested) if anyone is curious to try with arm64. The test case to compare the margin of error was eml_mul(2, 3).
local math = require("math")
function eml(x, y)
return math.exp(x) - math.log(y)
end
function eml_ln(x)
return eml(1, eml(eml(1, x), 1))
end
function eml_mul(x, y)
return eml(eml_ln(x) + eml_ln(y), 1)
end
function eml_add(x, y)
return eml_ln(eml(x, 1) * eml(y, 1))
end
Yeah, I simplified it a little. Though I must say I'm surprised pretty much every library I looked at uses the natural logarithm specifically, and not log2, which would seemingly be easier to compute with floats. Does anyone here know why, by any chance?
You can change base with a single multiplication by special constants if you accept 1ULP error, so working in one or the other doesn't really gain you much. But exp/logs are often implemented as a combination of a table lookup and polys, so you can just bake it into the constants even more easily.
Not sure why they wouldn't use log2 internally, but the mathematical reason is that e^x is the function whose derivative is itself, so it and the natural logarithm are fundamental to lots of other theories/operations like Euler's formula.
I have the impression that creating monotonic floating point function is particularly hard. At least I know that std::lerp is designed to be monotonic in its t parameter (not even in a or b) and its implementation has a number of branches. Although it has other guarantees as well, so I don't know how much of the implementation complexity can be assigned to the monotonicity requirement.
That's interesting! On my PC it reproduces under Lua 5.5 (`log_a x > log_b x`), but not under LuaJIT (`log_a x = log_b x`). I took a look at LuaJIT's implementation (https://github.com/LuaJIT/LuaJIT/blob/faaf663340347a78b22ed9...) and noticed that it always uses the `ln x / ln a` formula -- or, rather, `log_2 x / log_2 a`, which is just as correct I guess. Have you perhaps misinterpreted the results? (I do think it's valuable to add that this doesn't apply to LuaJIT though.)
Hm, perhaps, but I am confused now. In the article, you're comparing log_a x < log_b x, but now you're comparing log_a x > log_b x in your comment. To make it clear, I am running this code: https://bpa.st/ZCKA
I sort of needed to do that. I needed to compress data with an approximately geometric distribution, and as part of that process I needed to invert its CDF, which is `CDF = 1 - (1 - p)^x`. That translates to `x = log_(1 - p) (1 - CDF)`, which is variable over both the argument and the base. At that point I wondered how consistent the implementation of double-argument `log` is, since the encoder and the decoder need to agree about it, which led to this article.
The log base is a parameter, not a function, so that doesn't typecheck. Multivariate monotonicity isn't really a popular term, as far as I'm aware, so I'm not aware of good terminology for this. Maybe "log is non-monotonic with respect to the base"?
Wellll... Yes and no. And I want to nitpick "FP ops are imprecise" because it's important sometimes.
It does come up in Lua - Lua uses 64-bit double floats for everything, _even array indexing_, because they have 53 bits of mantissa and they're guaranteed to represent all 32-bit integers with 100% precision.
I just opened Lua and got `2 ^ 32 == 4294967296.0` and `2 ^ 32 + 1 == 4294967297.0`. You can store 4 billion things in a Lua table and access them with double float indexes.
The usual "0.1 + 0.2 != 0.3" is _not_ imprecision. You could dedicate 1,000 bits to a float and still find some example of a number that's trivial to represent in decimal but repeats forever in binary.
While I'm on it, fixed-point and floating-point aren't magically different. Floats work better on very big values. Fixed-points are more predictable but run out of range easily when squaring numbers. This comes in 3D math when finding the length of vectors or normalizing vectors.
The PlayStation 1 didn't have jiggly vertices and warpy textures because of fixed-point. It had jiggly vertices because its GPU didn't have subpixel-precise rendering, and it only had 16 bits of precision. It had warpy textures because it had affine texture mapping. The N64's GPU had more bits of precision and it had perspective-correct texture mapping. There's no 16-bit floating-point format that would have saved the PS1 from wobbling.
Also floats aren't the cause of T-junctions or "sparklies" that are common in 3D game levels. That will happen in any system with finite precision, and if fixed-point would fix it, we'd use fixed-point.
reply