Start complex min and max scans from an infinite identity - #4272
Start complex min and max scans from an infinite identity#4272ayaangazali wants to merge 3 commits into
Conversation
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.
|
Confirmed on an M5 Max, macOS 26.5, cpu stream, against The inclusive scan is already correct on main: it starts Test suites pass and it applies to current main on its own. This is one of four open PRs fixing NaN handling in different ops. I applied all |
The identity being fixed lives in backend/cpu/scan.cpp, but the default stream is the gpu, which has its own identity and was already correct. Without pinning, the test passed on macOS without exercising the fix. Both streams are checked now.
|
Thanks for running it, and the stream point was a real gap in my test rather than just a note. You are right that the default stream is the gpu, and Metal already has the correct identity: devices = [mx.cpu]
if mx.default_device() != mx.cpu:
devices.append(mx.default_device())
for device in devices:
with mx.stream(device):
...Confirmed it still catches the bug that way: reverting Your other observation matches what I found: only the exclusive form reads the identity, the inclusive one starts from element 0 and was already right on main. I put that in the comment so the next reader does not have to rediscover it. |
|
Ran it again and all good! |
An exclusive scan writes the identity of its operation into the first position. On the CPU that identity is picked like this:
complex64isinexactbut notfloating, so it takes the second branch.std::numeric_limitshas no specialization forcomplex64_t, somax()returns a value initializedcomplex64_t, which is0+0j. Complex comparison is lexicographic on the real part, so that zero beats every negative real part and the scan never recovers:cummingoes the same way whenever the data is non negative, andlogcumsumexpstarts from0+0jinstead of-inf: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) specializeLimits<complex_t<T>>to an infinite pair, soCumMaxthere already starts from{-inf, -inf}andCumMinfrom{inf, inf}. The CPU was the only backend without it. This gives the CPU the same identity.scan_extremereplaces 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 bareinf+0jwould lose to on the imaginary tiebreak.logcumsumexpkeeps its own ternary, widened toinexactso complex reaches-inf. It deliberately does not usescan_extreme: its identity has to compare equal to theminval == -inftest insideLogAddExp, and an infinite imaginary part would fail that and produce NaN.Verified that exclusive equals inclusive shifted by one across
cumsum,cumprod,cummax,cumminandlogcumsumexp, 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:
logcumsumexpon 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.pyand the C++ suite (249 cases, 3350 assertions) pass. The added test fails on main withcummax 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.