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 plainpmin/pmaxof 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 1above). - 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 16Cause 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
- Decide whether to retain unspecified zero signs and document the layout/backend dependence, or establish a portable tie rule.
- A portable rule needs coordinated scalar, packet, and horizontal-reduction changes, with performance measurements including x86. Changing only RVV's
PropagateNaNpath to positional ties would break the current agreement with its plain operation. - Add device checks for the existing bitwise, same-type propagation invariant in both CUDA constexpr modes. The newer
packetmath_gpu.cumin/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
packetmathparts 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.