min/max PropagateNaN: signed-zero results depend on scalar/packet paths on RVV, NEON, and CUDA

Summary

Signed-zero results still depend on the scalar/packet path on RVV10, NEON, and CUDA. This is now reproduced on master snapshot 2298fbd89fcc785d9026cc3de28092774054e2fa (2026-09-16).

The remaining question is whether Eigen should specify a portable zero-sign rule. The current NaN-propagation invariant passes the focused checks below.

Contract actually merged

The final version of !2865 (merged) (c095d7e78) establishes:

For ordered operands, pmin/pmax<PropagateNaN> must select the same result, including the sign of zero, as plain pmin/pmax of the same scalar or packet type.

It deliberately leaves tie selection unspecified across backends and between scalar and packet types. NaN payload identity is also unspecified. The current packetmath test states this explicitly.

This corrects the original description: there is no global “second zero operand / first NaN operand” contract. Generic CPU scalar min/max preserve the first operand on an ordered tie. The current test does not require scalar/packet agreement, and its signed-zero checks pass on RVV.

Verified behavior

For mixed +0 and -0, tested in both operand orders:

Path Zero-tie result Current propagation invariant
CPU scalar float/double/half First operand Pass
x86 SSE float/double packets First operand Pass
RVV float/double packets, LMUL 1/2/4 min = -0, max = +0 Pass
RVV half packets, LMUL 1/2 min = -0, max = +0 Pass
NEON float/double packets min = -0, max = +0 Pass
CUDA device scalar float/double, without relaxed constexpr min = -0, max = +0 Pass
CUDA device scalar float/double, with relaxed constexpr First operand Pass
CUDA device float4/double2, either constexpr mode min = -0, max = +0 Pass
CUDA device scalar Eigen::half, either constexpr mode First operand Pass

The focused probe checks all 64 pairs from +0, -0, +1, -1, both infinities, and two distinct quiet NaNs, including mixed packet lanes. It checks ordered-input bitwise agreement of plain/PropagateFast/PropagateNaN and the NaN semantics of PropagateNaN/PropagateNumbers. All pass. The existing packetmath_minmax_propagation_test, invoked independently, also passes unchanged for all eight RVV packet types.

RVV returns canonical NaNs. The x86 control returned the first NaN payload for scalars and the second for SSE packets, further illustrating why payload identity is not pinned.

Public reduction reproducer

Identical values can produce different zero signs depending on storage layout:

#include <Eigen/Core>
#include <cmath>
#include <cstdio>
#include <cstdlib>

int main(int argc, char** argv) {
  const Eigen::Index n = argc > 1 ? std::atoi(argv[1]) : 16;
  Eigen::VectorXf contiguous(n), storage(2 * n);
  Eigen::Map<Eigen::VectorXf, 0, Eigen::InnerStride<2>> strided(storage.data(), n);
  contiguous.setZero();
  contiguous(0) = -0.0f;
  strided = contiguous;
  std::printf("max signbit: contiguous=%d strided=%d\n",
              int(std::signbit(contiguous.maxCoeff<Eigen::PropagateNaN>())),
              int(std::signbit(strided.maxCoeff<Eigen::PropagateNaN>())));
}
  • RVV, VLEN=256, float length 16: contiguous +0, strided -0 (0 1 above).
  • NEON, float length 8: the same difference.
  • x86 SSE control: both return -0.

With the input signs reversed, the corresponding minimum is -0 for the contiguous view and +0 for the strided view on RVV/NEON. These differences are observable through signbit or the sign of 1 / result.

RVV reproduction, with EIGEN_SRC pointing at the tested snapshot:

riscv64-linux-gnu-g++-14 -O2 -std=c++14 -I"$EIGEN_SRC" \
  -march=rv64gcv_zvl256b_zfh_zvfh -mrvv-vector-bits=zvl \
  -DEIGEN_RISCV64_USE_RVV10 repro.cpp -o repro-rvv
qemu-riscv64-static -cpu rv64,v=true,vlen=256,elen=64,zfh=true,x-zvfh=true \
  -L /usr/riscv64-linux-gnu ./repro-rvv 16

Cause and CUDA configuration

CPU scalar numext::mini/maxi use std::min/max or comparison ternaries. RVV packets use masked vfmin/vfmax; NEON packets use vminq/vmaxq. The hardware operations resolve mixed-zero ties by sign.

For CUDA float/double scalars, MathFunctions.h uses fmin/fmax without relaxed constexpr and comparison ternaries with it. GPU float4/double2 packets use fmin/fmax in either mode. Eigen's nvcc test configuration enables --expt-relaxed-constexpr, so scalar/packet disagreement occurs in that configuration too.

The generic NaN wrapper remains necessary for number-preferring operations. Another correction to the original description: pselect is an ordinary function call, so op(a,b) is evaluated on NaN inputs; its result is discarded where needed. The current source comment already explains this correctly.

Remaining work

  1. Decide whether to retain unspecified zero signs and document the layout/backend dependence, or establish a portable tie rule.
  2. A portable rule needs coordinated scalar, packet, and horizontal-reduction changes, with performance measurements including x86. Changing only RVV's PropagateNaN path to positional ties would break the current agreement with its plain operation.
  3. Add device checks for the existing bitwise, same-type propagation invariant in both CUDA constexpr modes. The newer packetmath_gpu.cu min/max checks use value comparisons that equate both zero signs.

The existing signed-zero checks should remain intact. No test relaxation is needed for the verified behavior above. Related performance discussion: #3097 (closed).

Validation and limits

  • x86: GCC 13.3, C++14, -O2, native execution.
  • RVV/NEON: GCC 14.2 cross compilers, C++14, -O2, QEMU 8.2.2. RVV uses VLEN=256 and FP16 extensions. No native RISC-V/Arm hardware validation.
  • CUDA: nvcc 12.8, C++14, -O2 -arch=sm_89, RTX 4070 Laptop GPU, with and without --expt-relaxed-constexpr.
  • CUDA 13.4: both modes compile; the default-mode executable failed at its first CUDA allocation with a runtime initialization unknown error, before any kernel ran. CUDA 12.8 then ran successfully on the same GPU. The 13.4 runtime failure remains undiagnosed.
  • Broader RVV packetmath parts 1, 2, and 13 (r1 s1) fail earlier in transpose checks (packetmath.cpp:931 / packetmath_data_movement.h:209). Those failures remain unresolved; the isolated existing min/max helper passes. This is not a full-suite pass.
  • HIP, SYCL, signalling-NaN exception behavior, the full CUDA suite, and performance were not tested.
Edited by Rasmus Munk Larsen