Skip to content

torch.minimum and torch.maximum return a zero whose sign depends on the tensor length#193781

Description

@krrishapatel

馃悰 Describe the bug

torch.minimum and torch.maximum return a zero whose sign depends on the tensor length, because the scalar path and the vectorized path disagree.

import torch

for n in (7, 8):
    a = torch.full((n,), 0.0)
    b = torch.full((n,), -0.0)
    print(n, torch.minimum(a, b)[0].signbit().item())
# 7 False   (+0.0)
# 8 True    (-0.0)

The length where it flips is one full vector, so it moves with the dtype:

dtype flips at
float64 n=4
float32 n=8
float16 n=16
bfloat16 n=16

maximum is the same with the arguments swapped, torch.maximum(full(n,-0.0), full(n,0.0)) gives -0.0 short and +0.0 long.

Which one is right

The vectorized answer. numpy is sign aware and order independent, and so is torch.fmin / torch.fmax on CPU:

min(+0,-0) min(-0,+0) max(+0,-0) max(-0,+0)
numpy -0 -0 +0 +0
torch.fmin / fmax, CPU -0 -0 +0 +0
torch.minimum / maximum, CPU vectorized (arm64) -0 -0 +0 +0
torch.minimum / maximum, CPU scalar +0 -0 +0 -0

The scalar path just returns whichever argument came first.

Cause

The scalar lambda uses std::min / std::max, which compare with < and so ignore the sign of zero:

[](scalar_t a, scalar_t b) -> scalar_t {
if (a != a || b != b) {
return std::numeric_limits<scalar_t>::quiet_NaN();
} else {
return std::min(a, b);
}
},
[](Vectorized<scalar_t> a, Vectorized<scalar_t> b) {
return at::vec::minimum(a, b);
});

[](scalar_t a, scalar_t b) -> scalar_t {
if (a != a || b != b) {
return std::numeric_limits<scalar_t>::quiet_NaN();
} else {
return std::max(a, b);
}
},
[](Vectorized<scalar_t> a, Vectorized<scalar_t> b) {
return at::vec::maximum(a, b);
});

at::vec::minimum on NEON is vminq_f32, which is sign aware, hence the mismatch. fmax_kernel and fmin_kernel right below use std::fmin / std::fmax and get it right, which is why those two ops are consistent.

One thing to check in a fix: on x86 at::vec::minimum is _mm256_min_ps, and MINPS is documented to return the second operand when both are zero. So the vectorized path there should give +0 for min(-0,+0), meaning the answer depends on the architecture as well as the length. I only have arm64 to test on, so that part is from reading the code, not measured.

Related, same shape of problem

amin / amax return the sign of whichever element came first, where numpy is order independent:

import torch, numpy as np
print(torch.tensor([0.0, -0.0]).amin().signbit().item())  # False
print(np.array([0.0, -0.0]).min())                        # -0.0

MPS returns the second argument for all four of minimum, maximum, fmin, fmax, so fmin / fmax are order dependent there too:

import torch
a = torch.zeros(8, device="mps"); b = -torch.zeros(8, device="mps")
print(torch.fmin(a, b)[0].signbit().item(), torch.fmin(b, a)[0].signbit().item())  # True False

Fix

The CPU scalar path is a small change, keep the existing NaN check and pick the signed zero explicitly instead of calling std::min. Glad to send a PR for that if you want it. Whether the x86 vectorized path should get a fixup too is a perf call on a hot op, so I would rather ask than guess.

Found with a differential tester that holds the values fixed and varies the tensor length.

Versions

torch 2.15.0a0, CPU and MPS, macOS arm64.

cc @jgong5 @mingfeima @XiaobingSuper @sanchitintel @ashokei @jingxu10 @aditew01 @kulinseth @malfet @DenisVieriu97 @jhavukainen @aditvenk @Isalia20

Metadata

Metadata

Assignees

No one assigned

    Labels

    module: cpuCPU specific problem (e.g., perf, algorithm)module: mpsRelated to Apple Metal Performance Shaders frameworkmodule: numerical-reproducibilitytriagedThis issue has been looked at a team member, and triaged and prioritized into an appropriate module

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions