Skip to content

Fix index range size and partitioning when the range is wider than the index type - #815

Open
Arthur031221 wants to merge 1 commit into
taskflow:masterfrom
Arthur031221:fix-index-distance-overflow
Open

Arthur031221 wants to merge 1 commit into
taskflow:masterfrom
Arthur031221:fix-index-distance-overflow

Conversation

@Arthur031221

Copy link
Copy Markdown

Anyone who calls for_each_index, for_each_by_index or reduce_by_index over a range whose span does not fit in the index type (for example for_each_index(0u, UINT_MAX, 1u << 20) to walk a 32-bit space in 1 MiB blocks) got zero iterations and no error, and for_each_index(5, 5, 0) crashed with SIGFPE.

tf::distance computed (end - beg + step - 1) / step in the index type, which went wrong in three ways:

  • The sum overflows when the span is larger than T can hold. For signed types that is undefined behaviour and in practice a negative count that is clamped to 0; for unsigned types it wraps. Before the fix, on x86-64 with g++ 13:
distance<int>(0, INT_MAX, 2)                       -> 0   (expected 1073741824)
distance<int>(-1500000000, 1500000000, 1000000000) -> 0   (expected 3)
distance<unsigned>(0, UINT_MAX, 1u << 20)          -> 0   (expected 4096)
distance<long>(LONG_MIN, LONG_MAX, 1)              -> overflow (UBSan reports it)
  • For signed types an empty range with a zero step (beg == end, step == 0) passes is_index_range_invalid and then divides by zero.
  • std::max(T{0}, ...) does not compile for int8_t and int16_t because the division promotes to int.

Two more places had the same overflow one step later:

  • IndexRange::unravel and the N-dimensional box builders in partitioner.hpp compute the exclusive end of the last partition as beg + size * step. That is one step past the last index and wraps for the same ranges, so even with a correct distance the last partition was dropped. For example for_each_by_index over IndexRange<unsigned>(0, UINT_MAX, 1u << 20) counted 4095 elements instead of 4096 with the default partitioner on 4 workers, and other chunk sizes lost more.
  • for_each_index advanced the index once more after the last iteration (beg += inc, idx += inc), which is signed overflow when the last index is within one step of INT_MAX. UBSan reported it for for_each_index(0, INT_MAX, 1 << 20).

The fix computes the span in std::make_unsigned_t<T>, which always holds it, and returns 0 for an empty or zero-step range. A new helper tf::index_at returns the index at a position, or the range's own end when that position is not representable in T; unravel and the box builders use it. The for_each_index loops no longer step past the last index. Results for ranges that already worked are unchanged.

Tests added to unittests/test_for_each.cpp:

  • Distance.* checks the values above plus small types and empty ranges directly.
  • IndexRange1D.unravel.WideRange and ByIndex.WideRange.* check the last partition of 1D ranges and a 2D range with the guided, dynamic and static partitioners, and reduce_by_index, with 1 and 4 workers.
  • ForEachIndex.WideRange runs for_each_index over the int and unsigned block ranges and the (5, 5, 0) range with 1 and 4 workers and checks the iteration counts.

Run locally (g++ 13, C++20, -O1, Linux):

  • With the fix, test_for_each: 302 of 302 test cases pass, also when built with -fsanitize=undefined -fno-sanitize-recover=undefined; test_reduce: 342 of 342 pass; test_iterators: 41 of 41 pass. A comparison against sequential loops of 1D, 2D and 3D for_each_by_index and reduce_by_index with all three partitioners also showed no differences.
  • With the three source files reverted, test_for_each does not compile (the int8_t and int16_t cases). With those two lines removed, the Distance.SpanExceeds*, IndexRange1D.unravel.WideRange and ByIndex.WideRange.* cases fail their assertions, and Distance.SmallTypesAndEmptyRanges and ForEachIndex.WideRange die with SIGFPE.

No scheduling code changed, so there is no measurable performance impact: a few integer operations per range, and one fewer index increment per chunk.

…e index type

tf::distance computed (end - beg + step - 1) / step in the index type.
When the span of the range does not fit in that type, the signed sum
overflowed (undefined behaviour) and the unsigned sum wrapped, so
for_each_index, for_each_by_index and reduce_by_index silently ran zero
iterations, for example for_each_index(0u, UINT_MAX, 1u << 20) or
for_each_index(0, INT_MAX, 1 << 20). The signed path also divided by
zero for an empty range with a zero step, such as for_each_index(5, 5, 0),
and did not compile for int8_t and int16_t.

Compute the span in the unsigned type of the same width, which always
holds it, and return 0 for an empty or zero-step range.

IndexRange::unravel and the N-dimensional box builders had the same
problem one step later: the exclusive end of the last partition is one
step past the last index and wrapped, which dropped the last partition
(4095 of 4096 iterations with the default partitioner on 4 workers). Add
tf::index_at, which returns that end as the range's own end when it is
not representable. for_each_index also no longer advances its index past
the last iteration, which overflowed a signed index near INT_MAX.

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