PHP 和 Lua 中的对数函数是非单调的。
Log is non-monotonic in PHP and Lua

原始链接: https://purplesyringa.moe/blog/log-is-non-monotonic-in-php-and-lua/

数学理论规定,对于固定的 $x$,较大的底数 $a$ 应该得到较小的对数结果。然而,PHP 和 Lua 有时会违背这一原则。 作者指出,在某些极端情况下,即使 $a > b$,`log($x, $a)` 和 `log($x, $b)` 的计算结果也会与数学预期相矛盾。这种行为并非源于典型的浮点数误差,而是由这些语言实现 `log(x, base)` 函数的方式所致。为了计算任意底数的对数,语言通常使用换底公式:$\ln(x) / \ln(\text{base})$。但在底数为 10 或 2 时,PHP 和 Lua 会通过调用 `log10` 或 `log2` 等专门的高精度库函数来“投机取巧”。 这造成了一种隐蔽的不一致性:该语言混合使用了两种不同的计算方法——一种基于通用的自然对数公式,另一种基于经过专门优化的、更精确的函数。由于这些方法是独立实现的,它们可能会产生细微的舍入误差差异,从而导致结果出现非单调性。作者认为,尽管这是一种常见的实现疏忽,但它凸显了以牺牲一致性为代价来“优化”数学函数的危险性。

这篇 Hacker News 讨论探讨了为何 `log()` 等数学函数在不同编程语言中表现出非单调性及精度差异。 该讨论的主要观点包括: * **精度差异**:不同语言在进行计算(如 `2^3`)时会产生略微不同的结果。这源于底层数学库实现的差异:一些语言(如 Python 和 PHP)依赖于系统的 `libm` 实现,而另一些语言(如 Go 和 Node.js)则使用自定义的确定性实现,以确保在不同设备间保持一致。 * **制表者的困境(The Table-Maker’s Dilemma)**:参与者解释了对超越函数(如 `log` 或 `sin`)进行正确舍入的难度。要实现完美的精度在计算上代价高昂,因为从数学上讲,可能需要计算任意位数的数字才能确定最终的舍入值。 * **实现细微差别**:讨论澄清了所报告的误差(通常在 1 ULP,即最后一位的单位误差范围内)均符合 IEEE 754 浮点数规范,且通常被认为是可忽略不计的。 * **澄清事项**:用户纠正了术语使用,指出该问题具体在于“相对于底数的非单调性”,而非浮点数计算本身的缺陷,同时还破除了关于旧系统(如 PS1)为何会出现视觉渲染伪影的传言。
相关文章

原文

If a>b>1 and x>1, you can prove that logaxlogbx. (As a reminder, logax denotes the value t such that x=at.) This is very intuitive if you think about it: for “normal” numbers, the greater a, the smaller t you will need to get the same x.

Yet if you ask PHP what it thinks, it will tell you you’re wrong in a couple rare cases:

&LT?php
$x = 2.93;
$a = 10 + 2 ** -49;
$b = 10;
assert($a > $b);
var_dump(log($x, $a) log($x, $b));
var_dump(log($x, $a) == log($x, $b));

To be clear, this is not the usual floating-point inaccuracy. Everyone already knows floating-point operations are imprecise and it wouldn’t be fun to blog about. This example was deliberately engineered to trigger something different.

It would be completely natural if the produced result indicated logax=logbx instead of : not all real numbers can be exactly represented as floats, so rounding can cause close results to appear equal. In fact, on the same numbers, Python’s math.log produces the “equals” result. In PHP, however, the result somehow flips. And in Lua, too! But not in Rust or C#. All on the same machine and OS! How come?

Also note that I’m only changing the base: if I also changed the argument, I would find lots of counterexamples that work in any language, e.g.:

log(243 ** 3, 3 ** 3) != log(243, 3)

…because the formula used for log is imperfect. That’s not what we’re discussing.

Log base

To find why this happens, let’s discuss how languages usually implement math.log.

libm, the library that provides transcendental functions, exposes multiple functions for computing logarithms: log, log10, log2, etc. Each function handles a single base: log uses base e, log10 uses base 10, and so on. While there are no guarantees on their precision, they are usually pretty good, and at least monotonic (brute-force).

But there is no function for an arbitrary base, so languages providing a two-argument log have to cheat. Mathematically, logax=lnxlna, so you can compute any logarithm from two base-e ones.

It’s a little imprecise, which is why math.log(243^3, 3^3) == math.log(243, 3) fails: double rounding from ln and the division makes the computed value slightly off.

But this doesn’t explain the case when x is unchanged, which the post opens with. In that example, the numerator stays the same (lnx) and the denominator decreases (at least symbolically), and somehow that decreases the result? Weird, even for floats!

Even more weird is the fact that if you actually compute lna and lnb in PHP or Lua, you’ll find out that they round to the same value! So somehow neither the numerator, nor the denominator changed, but the result changed??

Solution

You probably already see the reason. b=10 is a pretty specific counter-example, and there’s a suspicious log10-shaped hole in the double-log precision error.

PHP and Lua don’t always use the lnxlna formula. For the bases that libm implements directly, i.e. base 10 and base 2, they call the corresponding functions (log10 and log2) without going through the natural logarithm. So the code in question doesn’t compare two lnxlna calculations, but rather log(x) / log(10 + eps) to log10(x). Since these are completely different methods, it shouldn’t be a surprise that they may result in errors in different directions.

The intention is good: when applicable, log10 provides a more precise, faster result. But combining two evaluation methods results in a discontinuity at the boundary, which breaks reasonable assumptions that the methods satisfy separately!

Rambling

I wouldn’t necessarily call this a bug, but it certainly is an overlooked issue. This is, unfortunately, quite common in the floating-point world: IEEE-754 itself is incredibly robust, but thoughtless implementation decisions here and there contaminate it to the point where people attribute just about any FP bug to intrinsic imprecision.

For Lua, which only provides math.log, but not math.log10, the right thing to do would be to add math.log10 and remove the special casing from math.log. This way, people who use fixed base 10 can use the faster and simpler math.log10 method, which could provide better precision boundaries, while people who use a variable base will benefit from not having an edge case to worry about. Oh wait, they did pretty much the exact opposite in Lua 5.2! I like it when people value correctness.

PHP, on the other hand, special-cases bases 10 and 2 despite already having log10, supposedly for people who don’t RTFM, which I wouldn’t put past their target audience, if only anyone could be insane enough to try to guess PHP function names. I am getting mixed signals, so I’d better finish this post before I get a headache.

Curiously, PHP and C# also special-case log(x, 1) to return NaN regardless of x, while Lua and Python typically return ±. Truly a diverse ecosystem.

Oh, and also: PHP’s signature for log says the default base is M_E (the approximation of e), but docs say log without a base returns the natural logarithm. So does log(x) compute loground(e)x or logex? (This question is more important than it seems: for instance, sin(M_PI) correctly returns a non-zero value, because M_PI is not exactly π.) Lua at least has the decency to weasel-word around it. Spoiler: it’s the latter, but in fact log(x, M_E) would produce the same result, because the rounding in lnxlna is bad enough that the two coincide.

联系我们 contact @ memedata.com