Skip to content

OPcache optimizer folds $cond ? -0.0 : 0.0 into a single -0.0 constant, losing IEEE signed zero #24063

Closed

Description

@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
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";
$ php repro.php
+0.0 (OK)
$ php -d opcache.enable_cli=1 repro.php
-0.0 (BUG)

Variant without match (also reproduces)

<?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";
$ 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
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";
$ 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):

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

; (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.:

$zero = 0.0 * ($numberValue < 0 ? -1.0 : 1.0);

This preserves correct IEEE signed-zero behavior under OPcache and JIT.

PHP Version

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)

Activity

  1. added a commit that references this issue on Oct 2, 2026
    87d728d
  2. added 3 commits that reference this issue on Oct 2, 2026
    0a8c4e0
    38b2036
    6249b46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions