[php-src] PR #23581: Fix expm1() losing precision on 32-bit x86
| From: | marc-mabe | Date: | Sat, 05 Sep 2026 09:46:39 +0000 |
| Subject: | [php-src] PR #23581: Fix expm1() losing precision on 32-bit x86 | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38323@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23581
Author: marc-mabe
### Problem
On 32-bit x86,
expm1() returns results that are off by many ULP compared to other
platforms, and that are not correctly rounded:
```php
var_dump(expm1(3)); // i386: float(19.08553692318767)
// other: float(19.085536923187668)
var_dump(expm1(10)); // i386: float(22025.465794806725)
// other: float(22025.465794806718)
```
Measured against e**x - 1 evaluated to 80 decimal digits and rounded to nearest double, over 720
sampled inputs, the i386 result is correctly rounded in only 81 cases (11.2%), with a mean error of
~12 ULP and a worst case in the hundreds of ULP.
This makes expm1() silently architecture-dependent, so comparisons against precomputed
constants fail, cached or serialized values differ between machines, and the error propagates into
anything built on expm1().
### Cause
init_executor() calls zend_init_fpu(), which clamps the x87 FPU to double
precision (a 53-bit mantissa) for the whole request so that PHP's own double
arithmetic behaves like IEEE-754 binary64 rather than carrying x87's 64-bit excess precision.
libm's expm1(), however, computes on that same x87 unit and relies on the extended
range internally in order to round correctly to double. While the clamp is in place it
loses that headroom, so expm1() inside PHP is markedly less accurate than the very same
libm expm1() is outside PHP.
### Fix
zend_expm1() in Zend/zend_float.h restores extended precision for the
duration of the libm call and truncates the result back to double on return, using the
XPFPA macros already in that header. PHP_FUNCTION(expm1) calls it.
Wherever XPFPA_HAVE_CW is 0 — x86-64, arm64, and every other target without x87
precision control — zend_expm1() compiles down to a plain expm1() call,
so no other platform is affected in behaviour or code generation.
Result on i386, same 720 inputs and same reference:
| build | correctly rounded |
| --- | --- |
| i386, before | 81/720 (11.2%) |
| i386, after | 714/720 (99.2%) |
The accuracy figures here are stated against a high-precision reference rather than against another
platform, because for expm1() no mainstream platform is a correct baseline: glibc on
arm64 is itself only correctly rounded for 641/720 (89.0%) of the same inputs. Comparing i386
against arm64 would therefore understate the improvement and mislabel some fixed cases as
regressions.
### Known limitations
This is a large improvement, not a guarantee. expm1(67) regresses by 1 ULP, because the
clamped result there is genuinely the correctly rounded one. It is the only regression in the
sample, and the test asserts only inputs that this change fixes.
### Tests
ext/standard/tests/math/expm1_fpu_precision.phpt.
All arguments are exact binary fractions and every expected value is the correctly rounded double,
written as the shortest decimal that round-trips back to it. Inputs were chosen from the
intersection of cases that are wrong on clamped i386, correct on i386 with this change, and correct
on x86-64/arm64.
Verified locally with a full make clean + ./configure + make
per configuration, on i386 and arm64:
| | without fix | with fix |
| --- | --- | --- |
| arm64 | pass | pass |
| i386 | **fail** | pass |
### Relationship to the other XPFPA PRs
This is the same class of bug as the pow()/fpow() and exp()
fixes and adds a helper to the same part of Zend/zend_float.h.
### Notes
* This is the same class of bug as #23578 and #23579 fix and adds a helper to the same part of
Zend/zend_float.h
* This PR is mostly LLM work - I manually verified
* Failing tests of phpt-only commit is visible here TODO
* This is the third PR - PR for sinh will follow