Skip to content

Fix pow(), ** and fpow() losing precision on 32-bit x86 - #23578

Open
marc-mabe wants to merge 2 commits into
php:masterfrom
marc-mabe:xpfpa_pow
Open

Fix pow(), ** and fpow() losing precision on 32-bit x86#23578
marc-mabe wants to merge 2 commits into
php:masterfrom
marc-mabe:xpfpa_pow

Conversation

@marc-mabe

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

Copy link
Copy Markdown
Contributor

Problem

On 32-bit x86, the ** operator, pow() and fpow() return results that are off by one or more ULP compared to every other platform:

var_dump(10 ** -2);          // i386:  float(0.010000000000000002)
                             // other: float(0.01)
var_dump(5 ** -4);           // i386:  float(0.0016000000000000007)
                             // other: float(0.0016)
var_dump(fpow(10.0, -2.0));  // i386:  float(0.010000000000000002)
                             // other: float(0.01)

var_dump(10 ** -2 === 0.01); // i386: false, everywhere else: true

The results are not merely different, they are less accurate: measured against a high-precision reference, the i386 values are not correctly rounded while the x86-64 and arm64 ones are. This makes exponentiation silently architecture-dependent, so float comparisons against literals fail, cached or serialized values differ between machines, and the error propagates into anything built on top.

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

Fix

zend_pow() 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. Both call sites go through it:

  • safe_pow() in Zend/zend_operators.c, which backs the ** operator and pow()
  • PHP_FUNCTION(fpow) in ext/standard/math.c, which called libm pow() directly and so did not benefit from a fix to safe_pow() alone

Wherever XPFPA_HAVE_CW is 0 — x86-64, arm64, and every other target without x87 precision control — zend_pow() compiles down to a plain pow() call, so no other platform is affected in behaviour or code generation.

The i386 failure is exactly the precision assertions; the reference points and the IEEE-754 cases pass in both states.

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 pow()
computes on the x87 unit and relies on the extended range to round
correctly to double, so while that clamp is in place exponentiation is off
by one or more ULP.

This matters because it makes exponentiation 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 it.

Three code paths reach libm pow() and each gets its own file:

  Zend/tests/pow_fpu_precision.phpt                 the ** operator
  ext/standard/tests/math/pow_fpu_precision.phpt    the pow() function
  ext/standard/tests/math/fpow_fpu_precision.phpt   the fpow() function

The operator and the function are covered by the same cases with the same
inputs, so the two files differ only in call syntax and share an identical
--EXPECT-- block. fpow() is kept separate because it bypasses safe_pow()
and calls libm pow() directly, and because it additionally asserts the
IEEE-754 special cases that must survive the FPU precision switch.

Every expected value is exactly the correctly rounded double of its
decimal literal, so a build that keeps full precision prints the short
literal back and compares identical to it. Exact powers of two are
included as reference points the clamp does not affect.

These tests fail on i386 and pass wherever XPFPA_HAVE_CW is 0, which
includes x86-64 and arm64. The following commit fixes i386.
Restore extended FPU precision around the libm pow() call and truncate
the result back to double, so that i386 returns the same correctly
rounded doubles as every other platform.

zend_pow() in zend_float.h wraps the call and is used from both
safe_pow(), which backs the ** operator and pow(), and fpow(), which
called libm pow() directly. It compiles down to a plain pow() wherever
XPFPA_HAVE_CW is 0, which includes x86-64 and arm64.
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