Skip to content

Commit 7e0f238

Browse files
authored
Wrap out-of-range float-to-integer astype casts to match NumPy (#3033)
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, whereas NumPy emits a plain C cast that, for narrow integer targets, truncates toward zero and wraps modulo the destination width (e.g. `float32(128)` becomes `int8(-128)`). `convert_impl` (used by `astype` and every copy-and-cast kernel) only normalized this to NumPy's wrapping behavior for *unsigned* destinations. Signed narrow integer targets fell through to a raw `static_cast` and therefore *saturated* on device, diverging from NumPy. The same inconsistency showed up within a single operation: `dpnp.tensor.linalg.trace` with an `int8` output dtype produced different results depending on whether the value went through the element-wise `astype` path (saturated) or the reduction path (wrapped). This PR generalizes the float-to-integer branch of `convert_impl` to funnel every conversion through a wider signed integer, relying on the well-defined integer narrowing to perform the modular wrap for both signed and unsigned narrow integer targets. 64-bit destinations keep their existing handling (unsigned routes negatives through `int64`; signed casts directly).
1 parent ce505f8 commit 7e0f238

3 files changed

Lines changed: 39 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ This release is compatible with NumPy 2.5.
8989
* Fixed `dpnp.ndarray.view` ignoring the USM element offset of a sliced array, which also caused `dpnp.einsum` to silently return wrong results for a single sliced operand with no summed index [#3037](https://github.com/IntelPython/dpnp/pull/3037)
9090
* Fixed `dpnp.all` and `dpnp.any` aborting when reducing over an empty axis (e.g. an array with a zero-length dimension) [#3021](https://github.com/IntelPython/dpnp/pull/3021)
9191
* Released the GIL before the blocking OneMKL DFT calls in the FFT extension [#3040](https://github.com/IntelPython/dpnp/pull/3040)
92+
* 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)
9293

9394
### Security
9495

dpnp/tensor/libtensor/include/utils/type_utils.hpp

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,24 @@ dstTy convert_impl(const srcTy &v)
9898
}
9999
else if constexpr (!std::is_integral_v<srcTy> &&
100100
!std::is_same_v<dstTy, bool> &&
101-
std::is_integral_v<dstTy> && std::is_unsigned_v<dstTy>) {
102-
// for negative values, cast through signed integer to get two's
103-
// complement wrapping
104-
using intermediateT =
105-
std::conditional_t<sizeof(dstTy) < sizeof(std::int32_t),
106-
std::int32_t, std::int64_t>;
107-
return (v < srcTy{0})
108-
? static_cast<dstTy>(static_cast<intermediateT>(v))
109-
: static_cast<dstTy>(v);
101+
std::is_integral_v<dstTy>) {
102+
// Out-of-range float-to-int casts are UB; SYCL saturates while NumPy
103+
// wraps. Funnel through a wider signed integer so the well-defined
104+
// integer narrowing reproduces NumPy's wrapping, e.g. f32(128) ->
105+
// i8(-128).
106+
if constexpr (sizeof(dstTy) < sizeof(std::int64_t)) {
107+
return static_cast<dstTy>(static_cast<std::int64_t>(v));
108+
}
109+
else if constexpr (std::is_unsigned_v<dstTy>) {
110+
// uint64: no wider signed type, so only negatives need int64
111+
return (v < srcTy{0})
112+
? static_cast<dstTy>(static_cast<std::int64_t>(v))
113+
: static_cast<dstTy>(v);
114+
}
115+
else {
116+
// int64: nothing wider to funnel through
117+
return static_cast<dstTy>(v);
118+
}
110119
}
111120
else {
112121
return static_cast<dstTy>(v);

dpnp/tests/tensor/test_usm_ndarray_ctor.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,12 @@
3939
import dpnp.tensor as dpt
4040
from dpnp.tensor import Device
4141

42+
from .elementwise.utils import _all_dtypes, _integral_dtypes, _real_fp_dtypes
4243
from .helper import (
4344
get_queue_or_skip,
4445
skip_if_dtype_not_supported,
4546
)
4647

47-
_all_dtypes = [
48-
"b1",
49-
"i1",
50-
"u1",
51-
"i2",
52-
"u2",
53-
"i4",
54-
"u4",
55-
"i8",
56-
"u8",
57-
"f2",
58-
"f4",
59-
"f8",
60-
"c8",
61-
"c16",
62-
]
63-
6448

6549
@pytest.mark.parametrize(
6650
"shape",
@@ -1039,6 +1023,22 @@ def test_astype_gh_2882():
10391023
assert dpt.all(r == expected)
10401024

10411025

1026+
@pytest.mark.usefixtures("suppress_overflow_encountered_in_cast_numpy_warnings")
1027+
@pytest.mark.parametrize("dst_dtype", _integral_dtypes)
1028+
@pytest.mark.parametrize("src_dtype", _real_fp_dtypes)
1029+
def test_astype_out_of_range_float_to_int(src_dtype, dst_dtype):
1030+
q = get_queue_or_skip()
1031+
skip_if_dtype_not_supported(src_dtype, q)
1032+
1033+
values = [0, 1, -1, 127, 128, -129, 255, 256, -256, 300, 60000, -60000]
1034+
x_np = np.asarray(values, dtype=src_dtype)
1035+
x = dpt.asarray(x_np, sycl_queue=q)
1036+
1037+
expected = x_np.astype(dst_dtype)
1038+
res = dpt.astype(x, dst_dtype)
1039+
assert dpt.all(res == dpt.asarray(expected, sycl_queue=q))
1040+
1041+
10421042
def test_copy():
10431043
try:
10441044
X = dpt.usm_ndarray((5, 5), "i4")[2:4, 1:4]
@@ -1350,7 +1350,7 @@ def test_full_dtype_inference():
13501350
assert np.issubdtype(dpt.full(10, 0.3 - 2j, dtype=rdt).dtype, np.floating)
13511351

13521352

1353-
@pytest.mark.parametrize("dt", ["f2", "f4", "f8"])
1353+
@pytest.mark.parametrize("dt", _real_fp_dtypes)
13541354
def test_full_special_fp(dt):
13551355
"""See gh-1314"""
13561356
q = get_queue_or_skip()
@@ -1434,7 +1434,7 @@ def test_full_strides():
14341434
assert np.array_equal(dpt.asnumpy(X), Xnp)
14351435

14361436

1437-
@pytest.mark.parametrize("dt", ["i1", "u1", "i2", "u2", "i4", "u4", "i8", "u8"])
1437+
@pytest.mark.parametrize("dt", _integral_dtypes)
14381438
def test_full_gh_1230(dt):
14391439
get_queue_or_skip()
14401440
dtype = dpt.dtype(dt)
@@ -1551,7 +1551,7 @@ def test_linspace_fp():
15511551
assert X.strides == (1,)
15521552

15531553

1554-
@pytest.mark.parametrize("dtype", ["f2", "f4", "f8"])
1554+
@pytest.mark.parametrize("dtype", _real_fp_dtypes)
15551555
def test_linspace_fp_max(dtype):
15561556
q = get_queue_or_skip()
15571557
skip_if_dtype_not_supported(dtype, q)

0 commit comments

Comments
 (0)