[php-src] Issue #24063: OPcache optimizer folds `$cond ? -0.0 : 0.0` into a single `-0.0` constant, losing IEEE signed zero

From: 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)

« previous php.bugs (#252880) next »