Repository navigation
Promote int32 to int64 when reducing with sum - #3965
Conversation
An int32 sum overflows at 2**31 and wraps to a negative value with no
indication: summing three copies of INT32_MAX returned 2147483645. scipp
already handles the same situation for bool, whose sum is a count that bool
cannot hold, by accumulating into an int64 variable. int32 is the identical
case and was simply missed.
The rule the code already implements is now stated in one place: accumulate
in a type that can hold the sum, and return the input type if it can
represent the result, otherwise the wider type. float32 keeps returning
float32 because only its precision is at stake, not its range, and sum_into
already accumulates it in double.
FillValue::ZeroNotBool is renamed to ZeroForSum, since it no longer singles
out bool.
Also fixes a pre-existing bug the promotion would otherwise have extended to
int32: groupby(...).sum() on masked data built its mask replacement with the
output's fill value, so for bool it produced an int64 zero to substitute into
bool data and threw
DTypeError: 'where' does not support dtypes 'bool', 'int64', 'bool'
The replacement is consumed before accumulation, so it must match the data's
dtype rather than the accumulator's -- the same substitution reduce_to_dims
already makes for the dense path.
`long` is 32 bits on Windows, so the expected value in the overflow test overflowed in the test itself rather than in scipp. `DatasetShapeChangingOpTest.sum_masked` is typed over int32, whose sum is now int64.
GroupbyMaskedDataArrayTest.sum sums data declared as `int`, which is int32 and so now yields int64. It was missed because the search for affected tests looked for `int32_t` and this file spells the same type `int`. The new masked_bool test compared against a dimensionless variable, but a bool variable defaults to unit `none` and the sum preserves the input's unit.
A bool array has unit None, which the sum preserves, while sc.scalar defaults to dimensionless. Unrelated to the promotion itself: bool already promoted before this branch.
jl-wynen
left a comment
There was a problem hiding this comment.
I am wondering, do we need the ability to detect and signal these kinds of problems? NumPy raises warnings on overflow as well as division by zero. Should we look into implementing this as well? It would catch overflow in int64. (This would be on top of this PR, not instead.)
| TYPED_TEST(DatasetShapeChangingOpTest, sum_masked) { | ||
| // int32 cannot contain its own sum, so the result is int64. | ||
| using Result = std::conditional_t<std::is_same_v<TypeParam, int32_t>, int64_t, | ||
| TypeParam>; |
There was a problem hiding this comment.
Good catch -- there was none for dense data. Added sum_masked_bool as a separate test: bool cannot join DataTypes, since the fixture's values make the mean expectations of the other tests in that suite meaningless.
| would be obtained from manually summing individual dimensions. | ||
|
|
||
| Inputs of dtype 'bool' or 'int32' cannot contain their own sum, so the result | ||
| has dtype 'int64'. Other dtypes are preserved. |
There was a problem hiding this comment.
This is a bit too simplistic. int32 can contain many sums. And int64 can also overflow.
| EXPECT_EQ(sum(var, Dim::X), makeVariable<int64_t>(Dims{Dim::Y}, Shape{2}, | ||
| sc_units::m, Values{4, 6})); | ||
| EXPECT_EQ(nansum(var), makeVariable<int64_t>(sc_units::m, Values{10})); | ||
| } |
There was a problem hiding this comment.
Is this not covered by parametrized tests above?
There was a problem hiding this comment.
Mostly yes: NansumTest covers int32 for both all-dims and with-dim, and the dtype claim is covered more sharply by the two tests right below. Removed.
| special_like, [](const auto &x) { | ||
| using T = std::decay_t<decltype(x)>; |
There was a problem hiding this comment.
Just a nitpick, but we use C++ 23 now:
| special_like, [](const auto &x) { | |
| using T = std::decay_t<decltype(x)>; | |
| special_like, []<class T>(const T &x) { |
| const auto mask_replacement = | ||
| special_like(Variable(data.data(), Dimensions{}), fill); | ||
| special_like(Variable(data.data(), Dimensions{}), | ||
| fill == FillValue::ZeroForSum ? FillValue::Default : fill); |
There was a problem hiding this comment.
Why do you handle ZeroForSum here instead of in special_like?
There was a problem hiding this comment.
Producing the promoted dtype is exactly what special_like does for ZeroForSum -- it builds the accumulator. The mask replacement is the opposite case: it is substituted into the input before accumulation, so it must keep the input's dtype.
You are right that it should not be spelled out twice, though -- the same ternary and comment sat in reduce_to_dims. Both now call mask_fill_value in core/flags.h, which carries the explanation (including a note on the alternative of dropping ZeroForSum and making the promotion a property of the reduction).
- Extract the ZeroForSum -> Default substitution for masked input elements into `mask_fill_value`, replacing the duplicated ternary and comment in `reduce_to_dims` and `groupby::reduce_`. - Reword the claim that bool and int32 "cannot contain their own sum": they are promoted because sums leave their range quickly, and int64 can still overflow silently. - Add a masked bool sum test for Dataset. bool cannot join the typed fixture, whose `mean` expectations are not meaningful for it. - Drop `sum_int32_accumulates_in_int64`, covered by the parametrized nansum tests and the two dtype tests next to it. - Use a C++23 templated lambda in `zeros_for_sum_like`.
The C++20 abbreviated template lambda introduced in 3b924cd broke CI on two of three platforms: MSVC fails to compile zero_init<T>::value() for Eigen::Vector3d (C2440), and on Ubuntu VectorReduceTest.sum_vector sums to the wrong result. Bisecting the branch's CI runs pins this to that lambda specifically -- it's the only change in that commit touching the Eigen::Vector3d dispatch path. The plain auto/decay_t lambda is functionally identical and is what passed on all platforms before. Co-Authored-By: Claude Sonnet 5 <[email protected]>
The bug
An
int32sum overflows at2**31and wraps, silently:There is no exception and no warning, and the result is not merely imprecise but meaningless — a large positive sum can come back negative.
The rule scipp already has
sum_intoandzeros_not_bool_likealready implement, between them, a consistent rule that int32 was simply left out of:sum_intoZeroNotBoolfloat32keeps returningfloat32because only its precision is ever at stake — its range accommodates any sum offloat32values, andsum_intoalready restores precision by accumulating indouble.boolandint32are the cases where the range is the problem, so the result type has to widen. That is what the existing comment abovesummeans by bool "cannot contain its sum"; int32 cannot either.This also matches numpy, which promotes integer reductions to the default integer type while leaving
float32alone.FillValue::ZeroNotBoolis renamed toZeroForSum, as it no longer singles out bool.The masked-groupby bug this uncovers
groupby(...).sum()over masked data builds its mask replacement with the output's fill value, then substitutes it into the data before accumulating. With a promoting fill value the replacement gets the accumulator's dtype rather than the data's, andwherehas no mixed-dtype overload. So this already fails onmain, for bool:Without a fix, promoting int32 would extend that failure to masked int32 data, which works today. The replacement is consumed before accumulation, so it must match the dtype of the data being masked, not that of the output — the same substitution
variable::reduce_to_dimsalready makes on the dense path. Maskedgroupby(...).sum()on bool data therefore starts working too.Effect on callers
sum,nansum,bins_sum,bins_nansum,groupby.sum,groupby.nansumand anything built on them (includinghistover int32-weighted binned data) now returnint64forint32input. Code that relied onint32in /int32out gets a wider dtype — and was silently wrong whenever the sum exceeded2**31.meanis unaffected in dtype: it already normalized tofloat64.Testing
sum/nansumof int32 return int64; a sum of threeINT32_MAXvalues is exact; int64/float32/float64 keep their dtype;special_likegains an int32 case mirroring the bool one; maskedgroupby.sumcovered for both int32 and bool.Both bugs above were reproduced against released scipp 26.8.0, and the new Python tests were confirmed to fail against it for the expected reason.