Join the discussion
Write your take first — we'll ask for email only when you're ready to publish.
- Hacker News
- I've tested this with both LuaJIT and Lua 5.5. It appears to only affect LuaJIT. Worth mentioning in the article I'd say.by Ennea
- 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.)
- Wouldn't a more applicable title be that the log base is non-monotonic?by kristianp
- 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"?
- Good find, but I'm curious if anyone is ever sweeping the base of the logarithm in real life.by eternauta3k
- 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.
- 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.by leni536
- In the past there were a lot of standard libraries that computed the transcendental functions with big errors.
Now there are several libraries that are guaranteed to produce correctly rounded results for any FP64 arguments.
However, the correct libraries are somewhat slower than the libraries that provide correct results for most, but not for all input arguments.
by adrian_b
They need not be base e. Any base will do as long as it is the same for both the numerator and the denominator> you can compute any logarithm from two base-e ones.by dmitrygr- 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?
- I find it interesting the blog author tied PHP and Lua together. PHP uses a JIT from Lua. Is this related to the log issue?by cwt137
- > PHP uses a JIT from Lua.
Wow, TIL! I was certain this is a mistake, but apparently PHP uses DynAsm (https://wiki.php.net/rfc/jit), which was developed for LuaJIT (https://luajit.org/dynasm.html). Cool stuff!
To answer your question, probably not. I dated the PHP change that added this "optimization" back to 2014 (https://github.com/php/php-src/commit/b547e1358d3846fad4cd0c...), while DynAsm only started being used around 2019 (https://wiki.php.net/rfc/jit). I think this is just convergent evolution.
(I'm putting "optimization" in quotes because it changes semantics.)
- I was prepared to read how its console logging function could reorder writes so that
could result inlog('a'); log('b');
or something, which would've been no less surprising.b aby kstrauser - 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.
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.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-16by lioeters - I assume these results are all amd64. Is this the same on arm64?by hparadiz
- 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?
- 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.pub fn log(self, base: f64) -> f64 { self.ln() / base.ln() }by codeflo - I am curious what the correct algorithm would be, if for no other reason than to find out how on god's green earth it could be slower than using a division as an erstwhile shortcut. 8I
- > 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.
by yeputons - 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.
- > 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.
- > The usual "0.1 + 0.2 != 0.3" is _not_ imprecision.
It _is_ imprecision. Even if you cannot represent 0.3 exactly, you can consider the closest representable number, and that is different than the sum of the closest numbers to 0.1 and 0.2 because doing all of this introduced imprecision.
by SkiFire13 - > The usual "0.1 + 0.2 != 0.3" is _not_ imprecision.
Correct. That’s using the wrong base. Decimal floating point doesn’t have that problem.
by brewmarche - > 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.
Lua 5.3 introduced a proper integer type. However, JavaScript still represents all numbers as doubles!
by spacechild1 - > The PlayStation 1 didn't have jiggly vertices and warpy textures because of fixed-point
Indeed modern GPUs still use fixed-point rasterizers.
by cmovq - 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.
- > Lua uses 64-bit double floats for everything
Not true in Lua >= 5.3. 64-bit signed integers are included in the number type now. And there is a math.ult function for treating them as unsigned.
And so 0x7fffffffffffffff is representable properly.
by mjmas