4 ms·
Log is non-monotonic in PHP and Lua
- eternauta3k 3mo agoGood find, but I'm curious if anyone is ever sweeping the base of the logarithm in real life.
- purplesyringa 3mo agoI 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.
- Ennea 3mo agoI've tested this with both LuaJIT and Lua 5.5. It appears to only affect LuaJIT. Worth mentioning in the article I'd say.
- purplesyringa 3mo agoThat'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/faaf663340347a78b22ed94c63c24fe090bd9784/src/lib_math.c#L58 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.)
- Ennea 3mo agoHm, 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 https://bpa.st/ZCKA Which prints, for LuaJIT: true true false And for Lua 5.5: true false false
- purplesyringa 3mo ago`log_a x < log_b x` is the mathematically correct result. Due to inherent rounding from floats being a finite representation of real numbers, `log_a x <= log_b x` would be expected from any correct implementation using floats. So either `true true false` or `true false true` would be reasonable. (I made a mistake in the previous comment, LuaJIT returns `<` for me, just like in your comment, not `=`.) The topic of the post is that in PHP and Lua (without LuaJIT), sometimes this inequality doesn't hold, and instead we get `log_a x > log_b x`, which is very incorrect and cannot be explained away by rounding. Does that make more sense?
- Ennea 3mo agoAhem. Clearly my math knowledge failing me here. Apologies, and thanks for clearing it up :)
- dmitrygr 3mo ago> you can compute any logarithm from two base-e ones. They need not be base e. Any base will do as long as it is the same for both the numerator and the denominator
- purplesyringa 3mo agoYeah, 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?
- stackghost 3mo agoNot 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.
- adrian_b 3mo agoThe only advantage of the hyperbolic logarithm (a.k.a. natural logarithm) is that the multiplicative factor is unity when computing its derivative or primitive. The binary logarithm can be computed faster and more accurately, except for arguments very close to 1. It is a historical accident that most standard libraries have been defined in the past to include natural logarithms instead of binary logarithms. Moreover lazy implementers sometimes have written a binary logarithm function that uses a pre-existing hyperbolic logarithm function, instead of the direct implementation that would be much more efficient. Derivatives and primitives are seldom computed, but the computational advantage of binary logarithms applies to each function evaluation. Even when derivatives or primitives are computed, in almost all applications there already exists another multiplicative constant in the formula that must be computed, which can absorb in it the factor "ln 2", so there is no increased computational cost. There exists absolutely no reason to ever use multiple bases with logarithms. One can choose to always use only binary logarithms and there exists no problem that would need other kinds of logarithms. Choosing a base for logarithms is equivalent with choosing a unit of measurement for them. Changing the base of logarithms is done by the same rules as changing the unit of measurement for any quantity. In the same way as there is no need to use multiple units of measurement for a quantity, there is no need to ever work with multiple bases for logarithms. Thus there is no reason for a programming language to include a function with 2 arguments like "log(arg, base)" instead of having only a function with 1 argument, e.g. "log2" (and the 2 constants LN_2 and LOG2_E).
- lioeters 3mo agoRecently 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.
- purplesyringa 3mo agoPython, 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/f24c62fbc342d616032734b714116e1fdb891445/src/base/ieee754.cc#L24 https://github.com/v8/v8/blob/f24c62fbc342d616032734b714116e...) Go avoids dynamic linking, so they also have their own implementation. (https://github.com/golang/go/blob/543ead71a8e7acc2bd6f326327a090b64902b6a8/src/math/log.go 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?
- lioeters 3mo agoNice, thanks for tracking down those links, fascinating. Apparently being off by 1 ULP is within IEEE 754 floating-point spec, and "effectively negligible".
- AlotOfReading 3mo ago
- cwt137 3mo agoI find it interesting the blog author tied PHP and Lua together. PHP uses a JIT from Lua. Is this related to the log issue?
- purplesyringa 3mo ago> 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 https://wiki.php.net/rfc/jit), which was developed for LuaJIT (https://luajit.org/dynasm.html 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/b547e1358d3846fad4cd0c86e2d2e9f5a9039b35 https://github.com/php/php-src/commit/b547e1358d3846fad4cd0c...), while DynAsm only started being used around 2019 (https://wiki.php.net/rfc/jit https://wiki.php.net/rfc/jit). I think this is just convergent evolution. (I'm putting "optimization" in quotes because it changes semantics.)
- kristianp 3mo agoWouldn't a more applicable title be that the log base is non-monotonic?
- purplesyringa 3mo agoThe 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"?
- kstrauser 3mo agoI was prepared to read how its console logging function could reorder writes so that log('a'); log('b'); could result in b a or something, which would've been no less surprising.
- ReactiveJelly 3mo ago> 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.
- cmovq 3mo ago> The PlayStation 1 didn't have jiggly vertices and warpy textures because of fixed-point Indeed modern GPUs still use fixed-point rasterizers.
- mjmas 3mo ago> 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.
- lexicality 3mo ago
- leni536 3mo agoI 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.
- adrian_b 3mo agoIn 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.
- phron3sis 3mo ago[dead]
- codeflo 3mo agoThere'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: pub fn log(self, base: f64) -> f64 { self.ln() / base.ln() } 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.
- yeputons 3mo ago> 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.
- kaathewise 3mo agoYeah, 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