From d234c9a10b5ebd9cd0f764331ec057f466fa19c3 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Wed, 19 Aug 2026 18:15:21 +0200 Subject: [PATCH 1/3] Wrap out-of-range float-to-integer astype casts to match NumPy Casting an out-of-range floating-point value to an integer type is undefined behavior in C++. SYCL devices resolve it by saturating to the destination's min/max, while NumPy emits a plain C cast that, for narrow integer targets, truncates toward zero and wraps modulo the destination width (e.g. float32(128) -> int8(-128)). dpnp targets NumPy compatibility, but convert_impl only normalized this for unsigned destinations, so signed narrow targets saturated instead of wrapping. This inconsistency surfaced as an Array API conformance failure in linalg.trace with an int8 output dtype, where the element-wise astype path saturated while the reduction path wrapped. Generalize convert_impl to funnel every float-to-integer conversion through a wider signed integer, relying on the well-defined integer narrowing to perform the modular wrap for both signed and unsigned narrow targets. Add a regression test covering signed and unsigned targets and reuse the shared dtype lists in the ctor tests. --- .../libtensor/include/utils/type_utils.hpp | 37 ++++++++++++----- dpnp/tests/tensor/test_usm_ndarray_ctor.py | 40 +++++++++---------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/dpnp/tensor/libtensor/include/utils/type_utils.hpp b/dpnp/tensor/libtensor/include/utils/type_utils.hpp index bb83c210b9fa..78baad6c310c 100644 --- a/dpnp/tensor/libtensor/include/utils/type_utils.hpp +++ b/dpnp/tensor/libtensor/include/utils/type_utils.hpp @@ -98,15 +98,34 @@ dstTy convert_impl(const srcTy &v) } else if constexpr (!std::is_integral_v && !std::is_same_v && - std::is_integral_v && std::is_unsigned_v) { - // for negative values, cast through signed integer to get two's - // complement wrapping - using intermediateT = - std::conditional_t; - return (v < srcTy{0}) - ? static_cast(static_cast(v)) - : static_cast(v); + std::is_integral_v) { + // Casting an out-of-range floating-point value to an integer type is + // undefined behavior. SYCL resolves this by saturating to the + // destination's min/max, whereas NumPy emits a plain C cast and + // inherits the host compiler's lowering: for narrow integer targets + // that truncates toward zero and then wraps modulo the destination + // width, e.g. float32(128) -> int8(-128). + // So reproduce the wrapping by funneling the value through a wider + // signed integer -- for which the float-to-int truncation is well + // defined over its range -- and rely on the well-defined integer + // narrowing to perform the modular wrap. + if constexpr (sizeof(dstTy) < sizeof(std::int64_t)) { + return static_cast(static_cast(v)); + } + else if constexpr (std::is_unsigned_v) { + // 64-bit unsigned destination: no wider signed integer is + // available to funnel through, so route only negative values + // through int64 to keep two's-complement wrapping well defined; + // non-negative values up to the unsigned maximum convert + // directly. + return (v < srcTy{0}) + ? static_cast(static_cast(v)) + : static_cast(v); + } + else { + // 64-bit signed destination: nothing wider to funnel through + return static_cast(v); + } } else { return static_cast(v); diff --git a/dpnp/tests/tensor/test_usm_ndarray_ctor.py b/dpnp/tests/tensor/test_usm_ndarray_ctor.py index 8a791524546c..9abf09e56cbf 100644 --- a/dpnp/tests/tensor/test_usm_ndarray_ctor.py +++ b/dpnp/tests/tensor/test_usm_ndarray_ctor.py @@ -39,28 +39,12 @@ import dpnp.tensor as dpt from dpnp.tensor import Device +from .elementwise.utils import _all_dtypes, _integral_dtypes, _real_fp_dtypes from .helper import ( get_queue_or_skip, skip_if_dtype_not_supported, ) -_all_dtypes = [ - "b1", - "i1", - "u1", - "i2", - "u2", - "i4", - "u4", - "i8", - "u8", - "f2", - "f4", - "f8", - "c8", - "c16", -] - @pytest.mark.parametrize( "shape", @@ -1039,6 +1023,22 @@ def test_astype_gh_2882(): assert dpt.all(r == expected) +@pytest.mark.usefixtures("suppress_overflow_encountered_in_cast_numpy_warnings") +@pytest.mark.parametrize("dst_dtype", _integral_dtypes) +@pytest.mark.parametrize("src_dtype", _real_fp_dtypes) +def test_astype_out_of_range_float_to_int(src_dtype, dst_dtype): + q = get_queue_or_skip() + skip_if_dtype_not_supported(src_dtype, q) + + values = [0, 1, -1, 127, 128, -129, 255, 256, -256, 300, 60000, -60000] + x_np = np.asarray(values, dtype=src_dtype) + x = dpt.asarray(x_np, sycl_queue=q) + + expected = x_np.astype(dst_dtype) + res = dpt.astype(x, dst_dtype) + assert dpt.all(res == dpt.asarray(expected, sycl_queue=q)) + + def test_copy(): try: X = dpt.usm_ndarray((5, 5), "i4")[2:4, 1:4] @@ -1350,7 +1350,7 @@ def test_full_dtype_inference(): assert np.issubdtype(dpt.full(10, 0.3 - 2j, dtype=rdt).dtype, np.floating) -@pytest.mark.parametrize("dt", ["f2", "f4", "f8"]) +@pytest.mark.parametrize("dt", _real_fp_dtypes) def test_full_special_fp(dt): """See gh-1314""" q = get_queue_or_skip() @@ -1434,7 +1434,7 @@ def test_full_strides(): assert np.array_equal(dpt.asnumpy(X), Xnp) -@pytest.mark.parametrize("dt", ["i1", "u1", "i2", "u2", "i4", "u4", "i8", "u8"]) +@pytest.mark.parametrize("dt", _integral_dtypes) def test_full_gh_1230(dt): get_queue_or_skip() dtype = dpt.dtype(dt) @@ -1551,7 +1551,7 @@ def test_linspace_fp(): assert X.strides == (1,) -@pytest.mark.parametrize("dtype", ["f2", "f4", "f8"]) +@pytest.mark.parametrize("dtype", _real_fp_dtypes) def test_linspace_fp_max(dtype): q = get_queue_or_skip() skip_if_dtype_not_supported(dtype, q) From cbe94a456a62890b2b07e98b49b18f00c003ee77 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Wed, 19 Aug 2026 18:16:34 +0200 Subject: [PATCH 2/3] Add changelog entry for the astype signed-integer wrap fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4779cdb96d..758e9076ad13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ This release is compatible with NumPy 2.5. * Fixed a crash in boolean-mask advanced indexing (`dpnp.ndarray` get/set item) when the selection is empty (e.g. a scalar `False` index that injects a length-0 axis) [#3019](https://github.com/IntelPython/dpnp/pull/3019) * Released the GIL before the remaining blocking OneMKL BLAS and LAPACK calls to prevent host tasks contention, completing the work started in [#2850](https://github.com/IntelPython/dpnp/pull/2850) [#3027](https://github.com/IntelPython/dpnp/pull/3027) * Fixed `dpnp.repeat` raising an unclear `TypeError` for a nested sequence of `repeats` [#3024](https://github.com/IntelPython/dpnp/pull/3024) +* Fixed `astype` casting an out-of-range floating point value to a signed narrow integer type saturating to the destination min/max instead of wrapping like NumPy, generalizing the earlier unsigned-only fix [#3033](https://github.com/IntelPython/dpnp/pull/3033) ### Security From ba09d528d763f70e8ff70ab39075db8bd70d498c Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Wed, 19 Aug 2026 23:04:32 +0200 Subject: [PATCH 3/3] Abbreviate convert_impl float-to-int cast comments --- .../libtensor/include/utils/type_utils.hpp | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/dpnp/tensor/libtensor/include/utils/type_utils.hpp b/dpnp/tensor/libtensor/include/utils/type_utils.hpp index 78baad6c310c..b62bfb38a2e6 100644 --- a/dpnp/tensor/libtensor/include/utils/type_utils.hpp +++ b/dpnp/tensor/libtensor/include/utils/type_utils.hpp @@ -99,31 +99,21 @@ dstTy convert_impl(const srcTy &v) else if constexpr (!std::is_integral_v && !std::is_same_v && std::is_integral_v) { - // Casting an out-of-range floating-point value to an integer type is - // undefined behavior. SYCL resolves this by saturating to the - // destination's min/max, whereas NumPy emits a plain C cast and - // inherits the host compiler's lowering: for narrow integer targets - // that truncates toward zero and then wraps modulo the destination - // width, e.g. float32(128) -> int8(-128). - // So reproduce the wrapping by funneling the value through a wider - // signed integer -- for which the float-to-int truncation is well - // defined over its range -- and rely on the well-defined integer - // narrowing to perform the modular wrap. + // Out-of-range float-to-int casts are UB; SYCL saturates while NumPy + // wraps. Funnel through a wider signed integer so the well-defined + // integer narrowing reproduces NumPy's wrapping, e.g. f32(128) -> + // i8(-128). if constexpr (sizeof(dstTy) < sizeof(std::int64_t)) { return static_cast(static_cast(v)); } else if constexpr (std::is_unsigned_v) { - // 64-bit unsigned destination: no wider signed integer is - // available to funnel through, so route only negative values - // through int64 to keep two's-complement wrapping well defined; - // non-negative values up to the unsigned maximum convert - // directly. + // uint64: no wider signed type, so only negatives need int64 return (v < srcTy{0}) ? static_cast(static_cast(v)) : static_cast(v); } else { - // 64-bit signed destination: nothing wider to funnel through + // int64: nothing wider to funnel through return static_cast(v); } }