Skip to content

Fix exp() losing precision on 32-bit x86 - #23579

Open
marc-mabe wants to merge 3 commits into
php:masterfrom
marc-mabe:xpfpa_exp
Open

Fix exp() losing precision on 32-bit x86#23579
marc-mabe wants to merge 3 commits into
php:masterfrom
marc-mabe:xpfpa_exp

Conversation

@marc-mabe

@marc-mabe marc-mabe commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Problem

On 32-bit x86, exp() returns results that are off by one or more ULP compared to other platforms, and — more importantly — that are not correctly rounded:

var_dump(exp(3));   // i386:  float(20.08553692318767)
                    // other: float(20.085536923187668)
var_dump(exp(10));  // i386:  float(22026.465794806725)
                    // other: float(22026.465794806718)
var_dump(exp(20));  // i386:  float(485165195.40979075)
                    // other: float(485165195.4097903)

Measured against e**x evaluated to 80 decimal digits and rounded to nearest double, over 720 sampled inputs:

build correctly rounded
i386, current 85/720 (11.8%)
x86-64 / arm64 720/720 (100%)

So this is not a case of two platforms disagreeing about an arbitrary last bit: i386 is wrong and the others are right. It makes exp() silently architecture-dependent, so comparisons against precomputed constants fail, cached or serialized values differ between machines, and the error propagates into anything built on exp().

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 exp(), 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 exp() inside PHP is markedly less accurate than the very same libm exp() is outside PHP.

Fix

zend_exp() 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(exp) calls it.

Wherever XPFPA_HAVE_CW is 0 — x86-64, arm64, and every other target without x87 precision control — zend_exp() compiles down to a plain exp() 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 85/720 (11.8%)
i386, after 714/720 (99.2%)

Known limitations

This is a large improvement, not a guarantee, and the remaining cases are worth stating explicitly:

  • exp(67) regresses by 1 ULP. The clamped result there is genuinely the correctly rounded one, so this input is made worse. It is the only regression in the sample.
  • Five inputs stay wrong either way: exp(128), exp(170), exp(198), exp(3.625) and exp(25.428571428571427). Their error is algorithmic rather than a matter of precision control — exp(3.625) returns the identical incorrect value at both 53-bit and 64-bit precision, and only changes if the FPU is dropped to single precision.

Reaching bit-identical results across architectures would require a libm whose double routines are binary64-exact on i386, which is a toolchain/distribution matter outside PHP's control. 99.2% appears to be the practical ceiling from PHP's side.

The test asserts only inputs that this change fixes; the six cases above are deliberately excluded.

Tests

ext/standard/tests/math/exp_fpu_precision.phpt.

Every expected value is the correctly rounded double, written as the shortest decimal that round-trips back to it. exp(0) and exp(1) are included as reference points that the clamp does not affect, so a failure points at the affected inputs rather than at the whole function.

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

The i386 failure is exactly the ten precision assertions; the two reference points and both identity checks pass in either state.

FreeBSD Note

On FreeBSD the reference result of exp(1) is still off by 1 ULP on msun.
Because of that I remove the section for "unaffected reference points" from the phpt.

Notes

On i386 the x87 FPU is the platform default and zend_init_fpu() clamps it
to double precision (53-bit mantissa) for the whole request. libm's exp()
computes with x87 internally and needs the extended range to deliver a
correctly rounded double, so while that clamp is in place exp() is off by
one or more ULP compared to every platform whose FPU has no precision
control.

This matters because it makes exp() silently architecture-dependent: the
same script prints different floats on i386 than on x86-64 or arm64,
comparisons against precomputed constants fail, and the error propagates
into anything built on exp().

Each expected value is the correctly rounded double, written as the
shortest decimal that round-trips back to it. exp(0) and exp(1) are
included as reference points that the clamp does not affect.

This test fails on i386 and passes wherever XPFPA_HAVE_CW is 0, which
includes x86-64 and arm64. The following commit fixes i386.
Restore extended FPU precision around the libm exp() call and truncate the
result back to double.

Why this matters: zend_init_fpu() clamps the x87 FPU to a 53-bit mantissa
for the whole request so that PHP's own double arithmetic behaves like
IEEE-754 binary64. glibc's exp(), however, computes on the x87 unit and
relies on the extended range to round correctly to double, so the clamp
costs exp() accuracy. The loss is observable from userland and makes exp()
silently architecture-dependent, which breaks float comparisons, cached or
serialized results, and any computation that accumulates the error.

Measured against e**x evaluated to 80 decimal digits and rounded to nearest
double, over 720 sampled inputs:

  i386 before this change     85/720 correctly rounded  (11.8%)
  i386 after this change     714/720 correctly rounded  (99.2%)
  arm64, unaffected          720/720 correctly rounded  (100%)

This is an improvement, not a guarantee. exp(67) regresses by 1 ULP,
because the clamped result there is genuinely the correctly rounded one.
Five further inputs -- exp(128), exp(170), exp(198), exp(3.625) and
exp(25.428571428571427) -- stay wrong, because their error is algorithmic
rather than a matter of precision control: exp(3.625) returns the same
incorrect value at both 53-bit and 64-bit precision, and only changes if
the FPU is dropped to single precision. Bit-identical results across
architectures would require a libm whose double routines are binary64-exact
on i386, which PHP cannot influence. The accompanying test asserts only
inputs that this change actually fixes.

zend_exp() in zend_float.h compiles down to a plain exp() wherever
XPFPA_HAVE_CW is 0, which includes x86-64 and arm64, so no other platform
is affected.
On FreeBSD x86-64 the reference point for `exp(1)` failed with
1 ULP off by lib msun and completely unrelated to this PR.

Expected Result   float(2.718281828459045)
Result on FreeBSD float(2.7182818284590455)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant