This is an automated email from the ASF dual-hosted git repository.
tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-tirx.git
The following commit(s) were added to refs/heads/main by this push:
new 05961665 [FIX][CUDA] Preserve NaNs in floating-point min and max (#23)
05961665 is described below
commit 05961665c7b97feb473be07b8ff49629d76af4ed
Author: LngelKyo <[email protected]>
AuthorDate: Sat Oct 24 05:32:14 2026 +0900
[FIX][CUDA] Preserve NaNs in floating-point min and max (#23)
This is apache/tvm#20387 re-targeted at this repository after the TIRx
sources moved here in apache/tvm#20803. The diff is the same apart from
the test file path and the module paths in the tests (`tvm.compile` ->
`tvm_tirx.tirx.compile`; `tvm.testing`, `tvm.runtime`, `tvm.cuda` -> the
`tvm_tirx.` forms the rest of the file uses).
### 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 apache/tvm#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 the host-side proposal apache/tvm#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 bound names; the vector
form expands per lane.
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;
```
bfloat16 constants: `PrintConst` printed a bfloat16 NaN or Inf constant
as `__float2bfloat16_rn(nanf/*nan*/)` / `(inff/*inf*/)`, which does not
compile (the float32 branch prints `CUDART_NAN_F` / `CUDART_INF_F`, and
the float16 branch delegates to it). The bfloat16 branch now delegates
to the float32 printer in the same way; finite bfloat16 constants print
as before. The bfloat16 rows of the test need it; it is a separate
commit.
### Tests
Folded into `tests/python/backend/cuda/codegen/test_basic.py` as one
parametrized test: for each dtype in {float32, float64, float16,
bfloat16} × {scalar, vec4}, a kernel computes the 15-row matrix of the
host-side test in #31 — min and max of (a, b), (0, a), (a, 0), (nan, a),
(a, nan); `max(a, b) + 1` and `max(0, a) + 1` (which catch the ternary
being parsed as `(x + cond) ? a : b`); the chained `C = 0; C = max(C,
3); C = max(C, 2)` row; `min(a, +inf)` and `max(a, −inf)` — and an int32
kernel computes min and max and must keep the base path. 10 cases, all
requesting the `backend_config` fixture, so they run on both the nvcc
and NVRTC paths (20 in total). For float32 the generated source is also
checked: the general rows emit the `||` form, the constant NaN rows
`CUDART_NAN_F`, a scalar constant rhs the reversed non-strict compare,
and no bare `min()`/`max()`.
The scalar path's SSA bindings must not stay alive across statements:
`SSAGetID` caches by printed text within the live scope and the
base-codegen `BufferStore` dispatch does not invalidate it, so `C[v] =
max(C[v], A[v]); C[v] = max(C[v], B[v])` would reuse the first read of
`C[v]`. Row 12 of the matrix (`C = 0; C = max(C, 3); C = max(C, 2)`)
pins this: a stale cached read gives 2.
Constant-operand fast paths (from the review of apache/tvm#20054): a
constant NaN lhs is the result (both operands are bound before the
classification, so the rhs is still evaluated); 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 the non-strict form (`<`
-> `>=`, `>` -> `<=`) so ties still take `b`. The three ternary forms
are parenthesized so they survive nesting in a compound expression (row
11 of the matrix); the constant-NaN-lhs form prints the constant alone.
The tests are written as plain TIRx (`@T.function` with `T.constexpr`
selections) for the transpiler parser.
### Verification
Before the move, in apache/tvm:
- RTX A6000 (sm_86) and L40 (sm_89), CUDA 13.0.88: `-k min_max` **74
passed** (nvcc + nvrtc); the whole `test_target_codegen_cuda.py` of the
time **440 passed, 6 skipped**; `test_gpu_codegen_allreduce.py` **328
passed, 330 skipped**; negative control with the predicate forced to
`return false`: **68 failed / 6 passed** (the 6 are the 4 int-vector and
2 chained cases; the chained ones are green by construction because the
control bypasses the new path); restored: **74/74**.
- apache/tvm Jenkins GPU job on the 2026-10-14 merge with main:
`test_min_max` **74 passed** (37 nvcc + 37 NVRTC), 0 failed.
- `[email protected]` check/format and `clang-format 20.1.8 --dry-run
--Werror` clean.
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.
After the move: the same patch applied onto this repository's `main`
(the C++ hunks apply unchanged at shifted offsets; the tests change only
the module paths above). I have not re-run the GPU tests against this
repository's build, so the numbers above are from apache/tvm; the
Jenkins GPU pipeline here runs this file on both compiler paths.
Partially addresses apache/tvm#19579 (CUDA side only). The host side (C
host and LLVM codegen, plus the constant folding) is #31 in this
repository; this PR does not touch those paths. The constant
classification here is local to the CUDA codegen (scalar `FloatImm`); it
can move to `src/codegen/min_max_utils.h` once #31 lands.
---
src/backend/cuda/codegen/codegen_cuda.cc | 123 +++++++++++++++++++++-
src/backend/cuda/codegen/codegen_cuda.h | 6 ++
tests/python/backend/cuda/codegen/test_basic.py | 133 ++++++++++++++++++++++++
3 files changed, 259 insertions(+), 3 deletions(-)
diff --git a/src/backend/cuda/codegen/codegen_cuda.cc
b/src/backend/cuda/codegen/codegen_cuda.cc
index 068779bd..32a91899 100644
--- a/src/backend/cuda/codegen/codegen_cuda.cc
+++ b/src/backend/cuda/codegen/codegen_cuda.cc
@@ -73,6 +73,25 @@ TVM_FFI_INLINE bool IsPackedFloat(const PrimType& ty) {
return IsFloat8(ty) || IsFloat6(ty) || IsFloat4(ty);
}
+// Constant-operand classification for min/max, the same classification as
+// the host side's min_max_utils.h (#31), scalar FloatImm only here. A
+// constant NaN lhs is the result (the rhs is still bound and evaluated); a
+// constant NaN rhs keeps the lhs on any comparison outcome; a constant
+// non-NaN operand lets the ternary skip its NaN clause (an ordered compare
+// against a constant is never NaN). A vector constant (Broadcast of a
+// FloatImm) takes the general per-lane path.
+TVM_FFI_INLINE const FloatImmObj* AsFloatImm(const PrimExpr& e) { return
e.as<FloatImmObj>(); }
+
+// NaN-preserving min/max is emitted for float, half and bfloat16: the C
+// ternary below keeps the NaN operand instead of discarding it (apache/tvm
+// PR #20054). Integer min/max stays as it is.
+TVM_FFI_INLINE bool IsFloatMinMaxNanPreserving(const PrimType& ty) {
+ bool is_fp = ty.MatchesCode(DLDataTypeCode::kDLFloat) &&
+ (ty.bits() == 16 || ty.bits() == 32 || ty.bits() == 64);
+ bool is_bf16 = ty.MatchesCode(DLDataTypeCode::kDLBfloat) && ty.bits() == 16;
+ return is_fp || is_bf16;
+}
+
} // namespace
std::string GetFP8Type(const PrimType& type_ty) {
@@ -736,6 +755,104 @@ void CodeGenCUDA::PrintVecConstructor(const PrimType& t,
std::ostream& os) {
PrintType(t, os);
}
+// min/max for CUDA, keeping a NaN operand (apache/tvm PR #20054), for
+// float16/bfloat16/float32/float64. For a NaN-free pair this is min()/max();
+// the lhs-NaN clause fires on the first operand and the rhs-NaN case is
+// covered because the ordering compare is false when either operand is NaN.
+template <typename T>
+void CodeGenCUDA::PrintMinMaxNanPreservingImpl(const T* op, const char* opstr,
+ std::ostream& os) { //
NOLINT(*)
+ PrimType op_ty = op->ty.template as_or_throw<PrimType>();
+ const char* cmp = (opstr[0] == 'm' && opstr[1] == 'i') ? "<" : ">";
+ if (!IsFloatMinMaxNanPreserving(op_ty)) {
+ // Integer / non-float min/max keeps the base-codegen path, including the
+ // vectorized per-lane expansion for lanes > 1.
+ this->CodeGenC::Dispatch_(op, os);
+ return;
+ }
+ if (op_ty.lanes() > 1) {
+ this->PrintVecBinaryOpNanPreserving(std::string(opstr), op_ty, op->a,
op->b, cmp, os);
+ return;
+ }
+ // Bind both operands once (SSAGetID, in the expression's own scope: the
+ // same binding the host side and PrintVecBinaryOp use), then reference the
+ // temporaries: the expressions must not be re-evaluated per clause. Both
+ // are bound before the constant classification below, so a constant NaN
+ // lhs still evaluates the rhs and only the select is dropped. A
+ // float32/float64 constant comes back from SSAGetID as itself (PrintConst
+ // registers its text with MarkConst); a float16/bfloat16 constant is
+ // wrapped in a conversion call that is not registered and gets a temporary
+ // like any other operand. The bindings live in their own scope so a later
+ // statement that prints the same text (e.g.
+ // `red_buf[0] = max(red_buf[0], shuffle_down(...))` repeated by warp
+ // reduction) does not hit the cache and read the pre-write value.
+ int ssa_scope = BeginScope();
+ std::string va = SSAGetID(PrintExpr(op->a), op->a.ty());
+ std::string vb = SSAGetID(PrintExpr(op->b), op->b.ty());
+ const FloatImmObj* ca = AsFloatImm(op->a);
+ const FloatImmObj* cb = AsFloatImm(op->b);
+ if (ca != nullptr && std::isnan(ca->value)) {
+ // a constant NaN lhs is the result; the rhs stays bound above.
+ os << va;
+ } else if (ca != nullptr) {
+ // lhs is a constant non-NaN: ordered compare, b's NaN makes the compare
+ // false and b is kept.
+ os << "((" << va << ' ' << cmp << ' ' << vb << ") ? " << va << " : " << vb
<< ')';
+ } else if (cb != nullptr && std::isnan(cb->value)) {
+ // rhs is a constant NaN: keep a on any comparison outcome.
+ os << "((" << va << " != " << va << ") ? " << va << " : " << vb << ')';
+ } else if (cb != nullptr) {
+ // rhs is a constant non-NaN: reverse to a non-strict compare so a's NaN
+ // makes the compare false and a is kept; ties take b.
+ const char* rcmp = (cmp[0] == '<') ? ">=" : "<=";
+ os << "((" << va << ' ' << rcmp << ' ' << vb << ") ? " << vb << " : " <<
va << ')';
+ } else {
+ os << "(((" << va << ' ' << cmp << ' ' << vb << ") || (" << va << " != "
<< va << ")) ? " << va
+ << " : " << vb << ')';
+ }
+ EndScope(ssa_scope);
+}
+
+void CodeGenCUDA::Dispatch_(const prim::MinObj* op, std::ostream& os) { //
NOLINT(*)
+ this->PrintMinMaxNanPreservingImpl(op, "min", os);
+}
+
+void CodeGenCUDA::Dispatch_(const prim::MaxObj* op, std::ostream& os) { //
NOLINT(*)
+ this->PrintMinMaxNanPreservingImpl(op, "max", os);
+}
+
+void CodeGenCUDA::PrintVecBinaryOpNanPreserving(const std::string& op, const
PrimType& t,
+ PrimExpr lhs, PrimExpr rhs,
const char* cmp,
+ std::ostream& os) { //
NOLINT(*)
+ std::string sret = name_supply_->FreshName("_");
+ this->PrintIndent();
+ this->PrintType(t, stream);
+ stream << ' ' << sret << ";\n";
+ int ssa_scope = BeginScope();
+ {
+ std::string vlhs = SSAGetID(PrintExpr(lhs), lhs.ty());
+ std::string vrhs = SSAGetID(PrintExpr(rhs), rhs.ty());
+ for (int i = 0, lanes = t.lanes(); i < lanes; ++i) {
+ std::ostringstream value_temp;
+ value_temp << "((";
+ PrintVecElemLoad(vlhs, lhs.ty(), i, value_temp);
+ value_temp << ' ' << cmp << ' ';
+ PrintVecElemLoad(vrhs, rhs.ty(), i, value_temp);
+ value_temp << ") || (";
+ PrintVecElemLoad(vlhs, lhs.ty(), i, value_temp);
+ value_temp << " != ";
+ PrintVecElemLoad(vlhs, lhs.ty(), i, value_temp);
+ value_temp << ")) ? ";
+ PrintVecElemLoad(vlhs, lhs.ty(), i, value_temp);
+ value_temp << " : ";
+ PrintVecElemLoad(vrhs, rhs.ty(), i, value_temp);
+ PrintVecElemStore(sret, t, i, value_temp.str());
+ }
+ }
+ EndScope(ssa_scope);
+ os << sret;
+}
+
void CodeGenCUDA::PrintVecBinaryOp(const std::string& op, const PrimType& t,
PrimExpr lhs,
PrimExpr rhs, std::ostream& os) { //
NOLINT(*)
// Declare the result.
@@ -1503,9 +1620,9 @@ inline void PrintConst(const FloatImmObj* op,
std::ostream& os, CodeGenCUDA* p)
PrimType op_ty = op->ty.as_or_throw<PrimType>();
// Type code is kBFloat
if (op_ty.MatchesElementType(DLDataTypeCode::kDLBfloat, 16)) {
- os << "__float2bfloat16_rn";
- os << '(' << std::hexfloat << op->value << 'f';
- os << "/*" << std::scientific << op->value << "*/";
+ os << "__float2bfloat16_rn" << '(';
+ FloatImm const_f32 = FloatImm(PrimType::Float(32), op->value);
+ PrintConst(const_f32.get(), os, p);
os << ')';
return;
}
diff --git a/src/backend/cuda/codegen/codegen_cuda.h
b/src/backend/cuda/codegen/codegen_cuda.h
index 133a6f70..f8292bab 100644
--- a/src/backend/cuda/codegen/codegen_cuda.h
+++ b/src/backend/cuda/codegen/codegen_cuda.h
@@ -83,6 +83,12 @@ class CodeGenCUDA final : public CodeGenC {
void Dispatch_(const FloatImmObj* op, std::ostream& os) final;
void Dispatch_(const CallObj* op, std::ostream& os) final;
void Dispatch_(const prim::CastObj* op, std::ostream& os) final;
+ void Dispatch_(const prim::MinObj* op, std::ostream& os) final; // NOLINT(*)
+ void Dispatch_(const prim::MaxObj* op, std::ostream& os) final; // NOLINT(*)
+ template <typename T>
+ void PrintMinMaxNanPreservingImpl(const T* op, const char* opstr,
std::ostream& os);
+ void PrintVecBinaryOpNanPreserving(const std::string& op, const PrimType& t,
PrimExpr lhs,
+ PrimExpr rhs, const char* cmp,
std::ostream& os);
void Dispatch_(const EvaluateObj* op) final;
void Dispatch_(const ReturnObj* op) final;
void Dispatch_(const BindObj* op) final;
diff --git a/tests/python/backend/cuda/codegen/test_basic.py
b/tests/python/backend/cuda/codegen/test_basic.py
index 0b2ee6d0..f2596f0b 100644
--- a/tests/python/backend/cuda/codegen/test_basic.py
+++ b/tests/python/backend/cuda/codegen/test_basic.py
@@ -791,6 +791,139 @@ def test_vectorized_intrin1(*, backend_config):
run_test(*func, "float16")
+def _min_max_module(dtype, form, n=12):
+ # One kernel per (dtype, form) computes a 15-row matrix (2 rows for
int32), the
+ # shape of the host side's test (#31): rows 0-4 min of (A,B), (0,A),
(A,0), (nan,A),
+ # (A,nan); 5-9 the same for max; 10-11 max(A,B)+1 and max(0,A)+1 (the
ternary must
+ # stay parenthesized before "+"); 12 chained C=0; C=max(C,3); C=max(C,2)
(a stale
+ # cached read of C gives 2); 13-14 min(A,+inf), max(A,-inf). The constants
are
+ # FloatImms built outside the kernel (-inf as its own: a negated float16
FloatImm is
+ # not folded): scalar takes codegen's constant fast paths (NaN constant
inline,
+ # constant rhs as a reversed non-strict compare), vec4 the per-lane "||"
path.
+ is_float = dtype != "int32"
+ rows = 15 if is_float else 2
+ zero, one = T.cast(0, dtype), T.cast(1, dtype)
+ nan = T.cast(float("nan"), dtype) if is_float else None
+ inf = T.cast(float("inf"), dtype) if is_float else None
+ ninf = T.cast(float("-inf"), dtype) if is_float else None
+
+ @I.ir_module
+ class Module:
+ @T.function
+ def main(A: T.Tensor((n,), dtype), B: T.Tensor((n,), dtype), C:
T.Tensor((rows, n), dtype)):
+ T.func_attr({"tirx.noalias": True})
+ if T.constexpr(form == "vec4"):
+ with T.launch_thread("blockIdx.x", n // 4) as i0:
+ for i1 in T.vectorized(4):
+ if T.constexpr(is_float):
+ C[0, i0 * 4 + i1] = T.min(A[i0 * 4 + i1], B[i0 * 4
+ i1])
+ C[1, i0 * 4 + i1] = T.min(zero, A[i0 * 4 + i1])
+ C[2, i0 * 4 + i1] = T.min(A[i0 * 4 + i1], zero)
+ C[3, i0 * 4 + i1] = T.min(nan, A[i0 * 4 + i1])
+ C[4, i0 * 4 + i1] = T.min(A[i0 * 4 + i1], nan)
+ C[5, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], B[i0 * 4
+ i1])
+ C[6, i0 * 4 + i1] = T.max(zero, A[i0 * 4 + i1])
+ C[7, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], zero)
+ C[8, i0 * 4 + i1] = T.max(nan, A[i0 * 4 + i1])
+ C[9, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], nan)
+ C[10, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], B[i0 *
4 + i1]) + one
+ C[11, i0 * 4 + i1] = T.max(zero, A[i0 * 4 + i1]) +
one
+ C[12, i0 * 4 + i1] = zero
+ C[12, i0 * 4 + i1] = T.max(C[12, i0 * 4 + i1],
T.cast(3, dtype))
+ C[12, i0 * 4 + i1] = T.max(C[12, i0 * 4 + i1],
T.cast(2, dtype))
+ C[13, i0 * 4 + i1] = T.min(A[i0 * 4 + i1], inf)
+ C[14, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], ninf)
+ else:
+ C[0, i0 * 4 + i1] = T.min(A[i0 * 4 + i1], B[i0 * 4
+ i1])
+ C[1, i0 * 4 + i1] = T.max(A[i0 * 4 + i1], B[i0 * 4
+ i1])
+ else:
+ with T.launch_thread("threadIdx.x", n) as i:
+ if T.constexpr(is_float):
+ C[0, i] = T.min(A[i], B[i])
+ C[1, i] = T.min(zero, A[i])
+ C[2, i] = T.min(A[i], zero)
+ C[3, i] = T.min(nan, A[i])
+ C[4, i] = T.min(A[i], nan)
+ C[5, i] = T.max(A[i], B[i])
+ C[6, i] = T.max(zero, A[i])
+ C[7, i] = T.max(A[i], zero)
+ C[8, i] = T.max(nan, A[i])
+ C[9, i] = T.max(A[i], nan)
+ C[10, i] = T.max(A[i], B[i]) + one
+ C[11, i] = T.max(zero, A[i]) + one
+ C[12, i] = zero
+ C[12, i] = T.max(C[12, i], T.cast(3, dtype))
+ C[12, i] = T.max(C[12, i], T.cast(2, dtype))
+ C[13, i] = T.min(A[i], inf)
+ C[14, i] = T.max(A[i], ninf)
+ else:
+ C[0, i] = T.min(A[i], B[i])
+ C[1, i] = T.max(A[i], B[i])
+
+ return Module
+
+
[email protected]
[email protected](not env.has_cuda(), reason="need cuda")
[email protected]("form", ["scalar", "vec4"])
[email protected]("dtype", ["float32", "float64", "float16",
"bfloat16", "int32"])
+def test_min_max_nan_preserving_cuda(dtype, form, *, backend_config):
+ n = 12
+ is_float = dtype != "int32"
+ mod = tvm_tirx.tirx.compile(
+ _min_max_module(dtype, form, n), target={"kind": "cuda",
"backend_config": backend_config}
+ )
+ # The CUDA header before the kernel has "||" in preprocessor lines, so the
text is
+ # checked from the kernel declaration on. float never falls back to bare
min()/max()
+ # (the general rows emit "||", the constant NaN rows CUDART_NAN_F, a
scalar constant
+ # rhs the reversed non-strict compare); int32 keeps the base min()/max()
path.
+ src = mod.mod.imports[0].inspect_source()
+ kernel = src[src.index("__global__") :]
+ if dtype == "float32":
+ assert "||" in kernel and "CUDART_NAN_F" in kernel, kernel
+ assert "min(" not in kernel and "max(" not in kernel, kernel
+ if form == "scalar":
+ assert " >= " in kernel or " <= " in kernel, kernel
+ elif not is_float:
+ assert "min(" in kernel and "max(" in kernel, kernel
+
+ if is_float:
+ inf = np.inf
+ a_np = np.array([np.nan, 1, np.nan, 0.0, -0.0, 3, 2, -5, inf, -inf, 1,
-1], dtype=dtype)
+ b_np = np.array([1, np.nan, np.nan, -0.0, 0.0, 2, 2, -4, 1, 1, inf,
-inf], dtype=dtype)
+ zero, nan = np.zeros(n, dtype=dtype), np.full(n, np.nan, dtype=dtype)
+ pairs = [(a_np, b_np), (zero, a_np), (a_np, zero), (nan, a_np), (a_np,
nan)]
+ rows = [
+ np.where(cmp(x, y) | np.isnan(x), x, y)
+ for cmp in (np.less, np.greater)
+ for x, y in pairs
+ ]
+ rows += [rows[5] + 1, rows[6] + 1, np.full(n, 3, dtype=dtype), a_np,
a_np]
+ else:
+ a_np = np.array([3, -5, 7, 0, -1, 9, 2, -8, 4, 4, -2, 0], dtype=dtype)
+ b_np = np.array([1, 9, -2, 4, -3, 9, 5, -8, 0, -4, -2, 1], dtype=dtype)
+ rows = [np.minimum(a_np, b_np), np.maximum(a_np, b_np)]
+ expected = np.stack(rows)
+ a = tvm_tirx.runtime.tensor(a_np, tvm_tirx.cuda(0))
+ b = tvm_tirx.runtime.tensor(b_np, tvm_tirx.cuda(0))
+ c = tvm_tirx.runtime.empty(expected.shape, dtype, tvm_tirx.cuda(0))
+
+ def run_and_check():
+ mod(a, b, c)
+ got = c.numpy()
+ if not is_float:
+ np.testing.assert_array_equal(got, expected)
+ return
+ # NaN lanes are checked with isnan (CUDART_NAN_F and a min/max
instruction
+ # normalize the payload); every finite lane, including the sign of
zero, bitwise.
+ nan_mask = np.isnan(expected)
+ np.testing.assert_array_equal(np.isnan(got), nan_mask)
+ bits = {2: "uint16", 4: "uint32", 8: "uint64"}[expected.dtype.itemsize]
+ np.testing.assert_array_equal(got.view(bits)[~nan_mask],
expected.view(bits)[~nan_mask])
+
+ tvm_tirx.testing.run_with_gpu_lock(run_and_check)
+
+
@pytest.mark.gpu
@pytest.mark.skipif(not env.has_cuda(), reason="need cuda")
def test_round_ties_to_even(*, backend_config):