[php-src] Issue #24063: OPcache optimizer folds `$cond ? -0.0 : 0.0` into a single `-0.0` constant, losing IEEE signed zero
| From: | dragomano | Date: | Fri, 02 Oct 2026 01:45:24 +0000 |
| Subject: | [php-src] Issue #24063: OPcache optimizer folds `$cond ? -0.0 : 0.0` into a single `-0.0` constant, losing IEEE signed zero | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-252880@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24063
Author: dragomano
### Description
**Description**
With OPcache enabled, a ternary of the form
$cond ? -0.0 : 0.0 is wrongly
constant-folded into an unconditional -0.0. The runtime condition is
discarded and both branches produce negative zero. Plain IEEE-754 signed-zero
semantics are silently broken.
The fold is context-sensitive (see the trigger notes and the non-reproducing
variant below): it happens with the ternary as a match arm expression and
also without match — assigned to a variable and then returned.
The real-world consequence: code that relies on signed zero (for example,
deriving ±Infinity as 1.0 / ±0.0) silently produces the wrong sign under
OPcache and the correct sign without it — so the same code base behaves
differently in a development environment (no OPcache) and in production
(OPcache on, which is the typical default).
**Minimal reproduction (as a match arm)**
```php
<?php
declare(strict_types=1);
function simplifyRound(float $numberValue, float $stepValue): float|int|null
{
if (is_infinite($stepValue)) {
return match ('to-zero') {
default => ($numberValue < 0 ? -0.0 : 0.0),
};
}
return null;
}
$zero = (float) simplifyRound(5.0, fdiv(1.0, 0.0));
echo pack('E', $zero) === pack('E', -0.0) ? "-0.0 (BUG)\n" :
"+0.0 (OK)\n";
```
```text
$ php repro.php
+0.0 (OK)
$ php -d opcache.enable_cli=1 repro.php
-0.0 (BUG)
```
**Variant without match (also reproduces)**
```php
<?php
declare(strict_types=1);
function g(float $n): float
{
$r = $n < 0 ? -0.0 : 0.0;
return $r;
}
$h = fn (): float => g(5.0);
$zero = $h();
echo pack('E', $zero) === pack('E', -0.0) ? "-0.0 (BUG)\n" :
"+0.0 (OK)\n";
```
```text
$ php repro-no-match.php
+0.0 (OK)
$ php -d opcache.enable_cli=1 repro-no-match.php
-0.0 (BUG)
```
**Variant that does NOT reproduce**
```php
<?php
declare(strict_types=1);
function d(float $n): float
{
return $n < 0 ? -0.0 : 0.0;
}
$zero = d(5.0);
echo pack('E', $zero) === pack('E', -0.0) ? "-0.0 (BUG)\n" :
"+0.0 (OK)\n";
```
```text
$ php -d opcache.enable_cli=1 repro-direct-return.php
+0.0 (OK)
```
Included to help isolate the cause: the assignment + return shape folds even
when g(5.0) is called directly from the top level, while this direct-return
shape does not fold even under OPcache. All three scripts above were run
several times on vanilla PHP 8.3.6 and 8.3.0 (Docker php:8.3.x-cli) and on
PHP 8.5.11 (Windows, NTS x64): the two reproducing ones printed -0.0 on
every OPcache run (5/5 on 8.3.6), the non-reproducing one printed +0.0 on
every run.
Notes on the trigger:
- match is **not** required: the ternary also folds when its result is
assigned to a variable and then returned (second repro; verified on vanilla
PHP 8.3.6 and 8.3.0, see above).
- All of these variants reproduce as well: the ternary nested inside another
ternary arm ('up' => $n > 0 ? ... : ($n < 0 ? -0.0 : 0.0)),
additional
match arms ('up'/'down' with fdiv(±1.0,
0.0) constants), an extra
if ($numberValue === 0.0) return $numberValue; guard, and a match arm
without the surrounding is_infinite() branch (checked on 8.5.11 and
8.3.6).
- The fold is context-sensitive and the exact minimal trigger is not
isolated. On vanilla builds the repros above are stable (every run fails).
But an independent tester on the Ubuntu 24.04 distro package
php8.3-cli 8.3.6-0ubuntu0.24.04.11 observed the match repro fail in only
4 of 5 runs (a single first run with OPcache printed the correct +0.0;
cause not identified) and, in another script, all three shapes (match arm,
direct return, assignment + return), called at the top level with literal
arguments, printed the correct +0.0, i.e. nothing folded there. The
contextual difference could not be identified.
**Evidence that the condition is dropped**
opcache.opt_debug_level dump of the no-match repro's function body
g()
(vanilla PHP 8.3.6, Docker php:8.3.6-cli; identical picture on PHP 8.5.11):
Before optimization (0x10000, ternary intact):
```text
0000 CV0($n) = RECV 1
0001 T2 = IS_SMALLER CV0($n) int(0)
0002 JMPZ T2 0005
0003 T3 = QM_ASSIGN float(-0)
0004 JMP 0006
0005 T3 = QM_ASSIGN float(0)
0006 ASSIGN CV1($r) T3
0007 VERIFY_RETURN_TYPE CV1($r)
0008 RETURN CV1($r)
0009 VERIFY_RETURN_TYPE
0010 RETURN null
```
After optimization (0x20000, condition and +0.0 branch gone,
-0.0 unconditional):
```text
; (after optimizer)
0000 CV0($n) = RECV 1
0001 CV1($r) = QM_ASSIGN float(-0)
0002 RETURN CV1($r)
```
The match repro collapses the same way (the arm's function body becomes
T2 = QM_ASSIGN float(-0); RETURN T2).
Note: depending on where the ternary sits, the folded constant can be
propagated further along the pipeline — in the real-world code this report
was minimized from, the after-dump showed it already substituted into the
next call (SEND_VAL_EX float(-0) 1). The dumps therefore may quote
different stages of the same fold, not two different bugs.
**Affected optimization passes**
Bisecting opcache.optimization_level (default 0x7FFEBFFF) shows the fold
only happens when **both** of these bits are set — clearing either one
prevents the wrong constant propagation (verified for both repro variants:
match on 8.5.11 and 8.6.0RC2, no-match on 8.3.6 and 8.5.11):
- 0x0020 — pass 6, Data Flow Analysis based optimization
- 0x0080 — pass 8, Sparse conditional constant propagation (SCCP)
Possibly: join_phi_values() in Zend/Optimizer/sccp.c (called from
sccp_visit_phi()) treats two constant phi inputs as the same value when
zend_is_identical() returns true, and zend_is_identical() compares
doubles with == (Zend/zend_operators.c), where -0.0 == 0.0
— so the
literals get merged by value instead of bitwise identity. Conclusion from
code reading, not dynamically verified. The DFA pass is involved in preparing
the value.
**Affected versions**
- PHP 8.3.35, 8.4.26, 8.5.11 and 8.6.0RC2 (NTS x64) tested — all affected
- Vanilla PHP 8.3.6 and 8.3.0 (Docker php:8.3.6-cli / php:8.3.0-cli,
Debian) — affected, so the behavior is present since at least 8.3.0
- Also reproduced on the Ubuntu 24.04 distro package
php8.3-cli 8.3.6-0ubuntu0.24.04.11 (NTS, Zend OPcache, JIT off) — a
backported build; see the trigger notes for its flakier behavior
- opcache.enable_cli=1 (or any OPcache-enabled SAPI), default
opcache.optimization_level=0x7FFEBFFF
- JIT is not required: opcache.jit=disable still shows the bug; JIT modes
inherit the already-folded opcodes and stay wrong
**Expected result**
$cond ? -0.0 : 0.0 yields +0.0 when $cond is false and
-0.0 when it
is true, with and without OPcache.
**Actual result**
With OPcache enabled the expression always yields -0.0, regardless of
$cond.
**Workaround**
Derive the signed zero at runtime from a non-constant operand so the
optimizer cannot fold it into a single constant, e.g.:
```php
$zero = 0.0 * ($numberValue < 0 ? -1.0 : 1.0);
```
This preserves correct IEEE signed-zero behavior under OPcache and JIT.
### PHP Version
```plain
PHP 8.5.11 (cli) (built: Sep 22 2026 13:51:38) (NTS Visual C++ 2022 x64)
Copyright (c) The PHP Group
Built by The PHP Group
Zend Engine v4.5.11, Copyright (c) Zend Technologies
with Xdebug v3.5.0, Copyright (c) 2002-2025, by Derick Rethans
with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies
```
### Operating System
Windows 11 Pro 25H2 (Laravel Herd 1.30, Docker Desktop 4.92)