Skip to content

[Fix][CUDA] Preserve NaNs in floating-point min and max - #20387

Open
LngelKyo wants to merge 7 commits into
apache:mainfrom
LngelKyo:fix/cuda-min-max-nan
Open

LngelKyo wants to merge 7 commits into
apache:mainfrom
LngelKyo:fix/cuda-min-max-nan

Conversation

@LngelKyo

@LngelKyo LngelKyo commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Bug: on main, the CUDA codegen prints bare min()/max() for T.min/T.max on float32/float16/bfloat16, so a NaN operand is dropped

With #20054's test data, the CUDA target gives 4/8 bitwise matches: the NaN lanes are discarded and the ±0 ties disagree with the C host.

Fix

Same semantics as #20054: emit (((a > b) || (a != a)) ? a : b) for max (and the < form for min), so a NaN on either side is preserved and ties take b. Scalar form binds each operand once via SSA and references the temporaries; the vector form expands the same expression per lane. Covers float16/bfloat16/float32/float64. Integer and other non-float min/max keep the existing base-CodeGenC path, including the per-lane vector expansion. Only the CUDA codegen is touched; the host side is #20054's.

Generated source (scalar and per-lane vector), from the branch:

[float32/scalar] C_ptr[((int)threadIdx.x)] = (((v_ > v__1) || (v_ != v_)) ? v_ : v__1);
[float32/vec4]   __1.x = ((v_.x > v__1.x) || (v_.x != v_.x)) ? v_.x : v__1.x;

Tests

Folded into tests/python/codegen/test_target_codegen_cuda.py: 4 dtypes (float32/float64/float16/bfloat16) × 2 ops × {scalar, vec4}, a composite case (C[i] = max(A[i], B[i]) + 1.0 — catches the ternary being parsed as (x + cond) ? va : vb; NaN lanes assert NaN, finite lanes bitwise), and an int32 vec4 regression against numpy. Both compile paths (nvcc, nvrtc) are covered by the autouse fixture. Also test_min_max_float_imm_operand_cuda (constant NaN / non-NaN × lhs / rhs × min / max × scalar / vec4, once per compile path), with source assertions: the scalar constant fast path contains no ||; a NaN lhs is the constant itself; a NaN rhs leaves only (a != a); a non-NaN constant leaves a single ordered compare (reversed non-strict when the constant is on the right); the vec4 form takes the general per-lane path. A nested case, max(0.0, B[i]) + 1.0, is added too. The constant has to be written inline as T.float32(c): a c = T.float32(...) statement in the prim_func body is a local buffer, and the earlier CI segfault was in the test prologue, not in the codegen.

A further defect surfaced in CI: the scalar path's SSA bindings stayed alive across statements. SSAGetID caches by printed text within the live scope and the base-codegen BufferStore dispatch does not invalidate it, so warp allreduce — which emits red_buf[0] = max(red_buf[0], shuffle_down(...)) repeatedly — read the pre-write value from the second statement on: the first CI run had 92 failures in test_gpu_codegen_allreduce, all finite values with max systematically too small. The scalar bindings are now scoped to the statement (BeginScope/EndScope, the same shape the vector path already had). A chained-statements test (C[v] = max(C[v], A[v]); C[v] = max(C[v], B[v]), C pre-filled) fails on the pre-fix code and passes with the fix.

Constant-operand fast paths (in response to tqchen's review on #20054): four cases — a constant NaN lhs is the result; a constant NaN rhs needs only the (a != a) test; a constant non-NaN lhs keeps the ordered compare; a constant non-NaN rhs reverses it to a non-strict one (< → >=, > → <=). A vector constant takes the general per-lane path.

The tests are written as plain TIRx (@T.prim_func with T.constexpr selections) for the transpiler parser (#20423 / #20437); the earlier S-TIR forms no longer parse on main.

Verification

RTX A6000 (earlier rounds) and L40 (sm_89), CUDA 13.0.88:

  • -k min_max: 74 passed (nvcc + nvrtc);
  • full test_target_codegen_cuda.py: 440 passed, 6 skipped;
  • allreduce suite: 328 passed, 330 skipped;
  • negative control (predicate forced to return false): 68 failed / 6 passed — the 6 are the 4 int-vector and 2 chained cases, the latter green by construction since the control bypasses the new path;
  • restored: 74/74.
  • ruff@0.12.3 check/format and clang-format 20.1.8 --dry-run --Werror clean (per the run recorded in this thread).

NaN constants and NaN results are only guaranteed to be NaN, not a particular payload (on sm_89 nvcc normalizes some forms to 0x7FFFFFFF); the tests assert isnan on the NaN lanes and bitwise equality on the finite lanes.

Re-verified 2026-10-02 on an RTX A6000 (sm_86, CUDA 13.0.88, nvcc and NVRTC paths): -k min_max 74 passed; the whole codegen file 440 passed / 6 skipped; test_gpu_codegen_allreduce.py 328 passed / 330 skipped; with the NaN-preserving predicate forced off, 68 of the 74 fail (negative control).

Partially addresses #19579 (CUDA side); host side is #20054.

cc @tlopex @yongwww @swjng — CI will likely need approval as before.

@LngelKyo
LngelKyo marked this pull request as draft September 18, 2026 18:08
@LngelKyo
LngelKyo marked this pull request as ready for review September 18, 2026 20:52
@LngelKyo
LngelKyo force-pushed the fix/cuda-min-max-nan branch from c87055f to 4232dd4 Compare September 21, 2026 22:53
@LngelKyo
LngelKyo marked this pull request as draft September 21, 2026 22:53
@LngelKyo
LngelKyo force-pushed the fix/cuda-min-max-nan branch from cbe6a06 to 5d7a3a0 Compare September 21, 2026 23:52
The CUDA codegen prints bare min()/max() for T.min/T.max, so a NaN
operand is dropped: with apache#20054's data only 4 of 8 lanes match bitwise,
and the +-0 ties disagree with the C host.

Emit ((a > b) || (a != a)) ? a : b for max (and the < form for min),
matching apache#20054's host-side semantics: a NaN on either side survives
and ties take b. Covers float16/bfloat16/float32/float64; integer and
other non-float min/max keep the base CodeGenC path, including the
per-lane vector expansion. Only the CUDA codegen is touched; the host
side is apache#20054's.

Operands are bound once via SSAGetID inside their own scope. The scope
matters: SSAGetID caches by printed text within the live scope and the
BufferStore dispatch does not invalidate it, so without it warp
allreduce -- which emits red_buf[0] = max(red_buf[0], shuffle_down(...))
repeatedly -- reads the pre-write value from the second statement on.

Constant operands take the simplified forms raised in the apache#20054
review: a constant NaN lhs is the result, a constant NaN rhs needs only
the NaN test, and a constant non-NaN operand drops the NaN clause,
using a reversed non-strict compare when the constant is on the right.
Vector constants keep the general per-lane path.

Tests go in tests/python/codegen/test_target_codegen_cuda.py, gpu-marked
and covering both compile paths through the autouse fixture: apache#20054's
data over four dtypes x {scalar, vec4}, the constant-operand matrix,
nested uses that would expose a missing parenthesis, a chained-statement
case that pins the SSA scoping, and an int32 vec4 regression against
numpy.
@LngelKyo
LngelKyo force-pushed the fix/cuda-min-max-nan branch from b629c8e to 6810d51 Compare September 22, 2026 00:08
The float_imm tests wrote `const_scalar = T.float32(...)` at the top of the
prim_func. TVMScript parses that assignment as a one-element host-side local
buffer plus a store, not as a constant: under CUDA the buffer is lowered to
device workspace (cudaMalloc) and the host then writes the device pointer,
which segfaults with no diagnostic. The kernel also read the operand back
through a load, so the constant fast path was never exercised. The constants
are now inlined into the min/max call, the vec4 form uses scalar indices, and
the NaN lanes are checked with isnan instead of bitwise equality because CUDA
emits a constant NaN as CUDART_NAN_F (0x7fffffff) while numpy uses 0x7fc00000.
Source assertions now prove which path each form took.

AsFloatImm narrows to scalar FloatImm: its Broadcast branch sat behind the
lanes() == 1 guard and was unreachable; vector constants take the general
per-lane path.
test_min_max_float_imm_operand_cuda compared every lane bitwise, and gave B's
NaN lane its own payload (0x7FC00033) so that "which operand was selected"
could be read off the payload. On sm_89 the nvcc-compiled min(c, B) and
max(B, c) forms return the canonical NaN (0x7FFFFFFF) for that lane: nvcc
lowers the compare-select to a min/max instruction that quiets the payload,
which is allowed. The contract of the NaN-preserving min/max is that the
result is NaN whenever an operand is NaN, not which NaN.

The check now asserts isnan on every lane whose expected value is NaN, no NaN
on the other lanes, and bitwise equality (sign of zero included) on the finite
lanes. Which operand was selected is still visible from the finite lanes.
@LngelKyo
LngelKyo marked this pull request as ready for review September 24, 2026 10:02
…lections

Main now builds the codegen tests as plain TIRx (apache#20437) and selects a Python
branch in a prim_func body only when the condition is marked with T.constexpr
(apache#20423). Write the min/max tests the same way: no S-TIR blocks, direct
indexing inside the thread-bound and vectorized loops, and T.constexpr for the
op, constant-side and form selections. What the tests check is unchanged.

The float-imm test's comment no longer describes what a body assignment turns
into; only the reason for writing the constant inline is kept.
@LngelKyo
LngelKyo marked this pull request as draft September 29, 2026 15:01
@LngelKyo
LngelKyo marked this pull request as ready for review October 2, 2026 21:51
@LngelKyo

LngelKyo commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@tvm-bot rerun

This branch has not been deployed

No deployments
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