Skip to content

Start complex min and max scans from an infinite identity - #4272

Open
ayaangazali wants to merge 2 commits into
ml-explore:mainfrom
ayaangazali:complex-scan-identity
Open

Start complex min and max scans from an infinite identity#4272
ayaangazali wants to merge 2 commits into
ml-explore:mainfrom
ayaangazali:complex-scan-identity

Conversation

@ayaangazali

@ayaangazali ayaangazali commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

An exclusive scan writes the identity of its operation into the first position. On the CPU that identity is picked like this:

auto init = (issubdtype(in.dtype(), floating))
    ? static_cast<U>(std::numeric_limits<float>::infinity())
    : std::numeric_limits<U>::max();

complex64 is inexact but not floating, so it takes the second branch. std::numeric_limits has no specialization for complex64_t, so max() returns a value initialized complex64_t, which is 0+0j. Complex comparison is lexicographic on the real part, so that zero beats every negative real part and the scan never recovers:

>>> a = mx.array([-3+1j, -1+2j, -4+0j, -5+5j])
>>> mx.cummax(a, axis=0, inclusive=True)
array([-3+1j, -1+2j, -1+2j, -1+2j], dtype=complex64)
>>> mx.cummax(a, axis=0, inclusive=False)
array([0+0j, 0+0j, 0+0j, 0+0j], dtype=complex64)

cummin goes the same way whenever the data is non negative, and logcumsumexp starts from 0+0j instead of -inf:

>>> b = mx.array([3+1j, 1+2j, 4+0j, 0+5j, 2-1j])
>>> mx.cummin(b, axis=0, inclusive=False)
array([0+0j, 0+0j, 0+0j, 0+0j, 0+0j], dtype=complex64)

The inclusive results are correct throughout, so the two disagree: an exclusive scan should equal the inclusive one shifted by a position, and for complex it does not.

Neither GPU backend has this problem: both Metal (mlx/backend/metal/kernels/utils.h) and CUDA (mlx/backend/cuda/device/utils.cuh) specialize Limits<complex_t<T>> to an infinite pair, so CumMax there already starts from {-inf, -inf} and CumMin from {inf, inf}. The CPU was the only backend without it. This gives the CPU the same identity. scan_extreme replaces the three copies of the ternary, and picks the pair {inf, inf} for complex so the identity also wins against an input whose real part is itself infinite, which a bare inf+0j would lose to on the imaginary tiebreak.

logcumsumexp keeps its own ternary, widened to inexact so complex reaches -inf. It deliberately does not use scan_extreme: its identity has to compare equal to the minval == -inf test inside LogAddExp, and an infinite imaginary part would fail that and produce NaN.

Verified that exclusive equals inclusive shifted by one across cumsum, cumprod, cummax, cummin and logcumsumexp, over 8 dtypes, shapes (7,), (3,5), (2,3,4), (1,) and (9,2), every axis, and both scan directions. Complex goes from broken to matching; the real dtypes are byte identical to before and still match numpy.

Out of scope and left alone: logcumsumexp on an unsigned integer input starts from 0 because there is no representable -inf, which is a different problem and predates this.

CPU only. test_ops.py, test_reduce.py, test_array.py, test_compile.py, test_vmap.py, test_autograd.py, test_nn.py and the C++ suite (249 cases, 3350 assertions) pass. The added test fails on main with cummax reverse=False.

I am a freshman working through this codebase and I used Claude Code alongside it. Every result above came from a run here.

An exclusive scan writes the identity of its operation into the first
position. That identity came from std::numeric_limits, which has no
specialization for complex64_t and so handed back zero. Zero then won
every comparison against a negative real part, and cummax and cummin
returned all zeros. The CUDA backend already uses an infinite pair.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant