-
Notifications
You must be signed in to change notification settings - Fork 13.6k
[libc++] std::ranges::advance
: avoid unneeded bounds checks when advancing iterator
#84126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[libc++] std::ranges::advance
: avoid unneeded bounds checks when advancing iterator
#84126
Conversation
@llvm/pr-subscribers-libcxx Author: Jan Kokemüller (jiixyj) ChangesCurrently, the bounds check in Thus, if (it != s) {
++it;
} This difference in behavior matters when the check involves some "expensive" logic. For example, the Swapping around the checks in the Full diff: https://github.com/llvm/llvm-project/pull/84126.diff 1 Files Affected:
diff --git a/libcxx/include/__iterator/advance.h b/libcxx/include/__iterator/advance.h
index 7959bdeae32643..296db1aaab6526 100644
--- a/libcxx/include/__iterator/advance.h
+++ b/libcxx/include/__iterator/advance.h
@@ -170,14 +170,14 @@ struct __fn {
} else {
// Otherwise, if `n` is non-negative, while `bool(i != bound_sentinel)` is true, increments `i` but at
// most `n` times.
- while (__i != __bound_sentinel && __n > 0) {
+ while (__n > 0 && __i != __bound_sentinel) {
++__i;
--__n;
}
// Otherwise, while `bool(i != bound_sentinel)` is true, decrements `i` but at most `-n` times.
if constexpr (bidirectional_iterator<_Ip> && same_as<_Ip, _Sp>) {
- while (__i != __bound_sentinel && __n < 0) {
+ while (__n < 0 && __i != __bound_sentinel) {
--__i;
++__n;
}
|
The tests don't catch this at the moment. I'm thinking about how to test this. What do you think about adding a counter for |
✅ With the latest revision this PR passed the C/C++ code formatter. |
I added some tests that check that the equality comparison operator isn't called unnecessarily by adding another counter to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot for noticing and fixing this! Almost LGTM except a couple questions about the test.
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
assert(it.equals_count() == 0); | ||
} else { | ||
assert(it.equals_count() > 0); | ||
assert(it.equals_count() == M || it.equals_count() == M + 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: why are there two possibilities?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The M case is the "normal" case where the iterator is incremented without the bound being "hit" (it may equal the bound though, but this last equals check shouldn't be executed).
The "M + 1" case happens when the advancing while
loop is exited because of the bounds check, so the bound is "hit". Example: Calling std::ranges::advance(it, 999999, s)
on a range { 42, 3, 4 }
(size 3) will do 4 (3 + 1) bounds checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for explaining! In that case, we should either pass the expected number of comparisons as a parameter to this function, or pass a boolean parameter to indicate whether we expect the algorithm to hit the sentinel or not. Test cases should be simple so that we can be sure exactly what we're testing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes perfect sense. I'll update the test accordingly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done!
@@ -213,6 +221,20 @@ constexpr bool test() { | |||
assert(i == iota_iterator{INT_MIN+1}); | |||
} | |||
|
|||
// Check that we don't do an unneeded bounds check when decrementing a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: can you briefly explain why this case is special?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The existing tests for the "backwards" case seem to exclude bidirectional iterators:
Lines 191 to 199 in 1d296ec
{ | |
// Note that we can only test ranges::advance with a negative n for iterators that | |
// are sized sentinels for themselves, because ranges::advance is UB otherwise. | |
// In particular, that excludes bidirectional_iterators since those are not sized sentinels. | |
int* expected = n > size ? range : range + size - n; | |
check_backward<random_access_iterator<int*>>(range, range+size, -n, expected); | |
check_backward<contiguous_iterator<int*>>( range, range+size, -n, expected); | |
check_backward<int*>( range, range+size, -n, expected); | |
} |
So the tests didn't cover the part of std::ranges::advance
where the iterator is decremented "one by one".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why the comment says that being a sized sentinel is a requirement -- at least in the current draft of the Standard, all I see is a requirement that the iterator and the sentinel are the same type. IIUC, if the comment were true, the new test case would actually be UB (because it calls advance
with a negative n
and a bidirectional iterator).
Assuming the comment is not correct, IMO the !sized_sentinel_for
check creates more confusion than clarity. The bidirectional iterator concept doesn't require the type to be a sized sentinel for itself, and in tests we use "minimal" iterator types (types that only support the bare minimum operations required to satisfy the concept). So I think this would be clearer if we simply test with a bidirectional iterator and omit the static_assert
s and the comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that comment does not seem to be correct.
My intention with the !sized_sentinel_for
check was to not fall into this case: https://eel.is/c++draft/iterators#range.iter.op.advance-6.1, but instead test the other one: https://eel.is/c++draft/iterators#range.iter.op.advance-6.2.
But it should actually be possible to remove the new, "special case" test and instead improve the generic check_backward
tests, right? Because as you mentioned, being sized_sentinel_for<It, It>
is not actually a precondition for "advance
negative n
". In an improved check_backward
test, one would only have to ensure the actual preconditions:
[bound, i)
is a range- that
It
is astd::bidirectional_iterator
- that
S
(the sentinel type) is the same asIt
Then, one could instantiate check_backward
with different iterator types that either model sized_sentinel_for<It, It>
or don't. This way it would be possible to get coverage for both "range.iter.op.advance-6.1" and "range.iter.op.advance-6.2". Does this sound sensible to you?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I've removed that misleading comment for the check_backward
tests and adapted them to take bidirectional iterators. Then, I could also remove my "special case" test I had added, since the check_backward
tests now cover the "decrement one by one" case as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ldionne Just wanted to double-check with you regarding the comment in case we're missing something.
assert(it.equals_count() == 0); | ||
} else { | ||
assert(it.equals_count() > 0); | ||
assert(it.equals_count() == M || it.equals_count() == M + 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for explaining! In that case, we should either pass the expected number of comparisons as a parameter to this function, or pass a boolean parameter to indicate whether we expect the algorithm to hit the sentinel or not. Test cases should be simple so that we can be sure exactly what we're testing.
@@ -213,6 +221,20 @@ constexpr bool test() { | |||
assert(i == iota_iterator{INT_MIN+1}); | |||
} | |||
|
|||
// Check that we don't do an unneeded bounds check when decrementing a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why the comment says that being a sized sentinel is a requirement -- at least in the current draft of the Standard, all I see is a requirement that the iterator and the sentinel are the same type. IIUC, if the comment were true, the new test case would actually be UB (because it calls advance
with a negative n
and a bidirectional iterator).
Assuming the comment is not correct, IMO the !sized_sentinel_for
check creates more confusion than clarity. The bidirectional iterator concept doesn't require the type to be a sized sentinel for itself, and in tests we use "minimal" iterator types (types that only support the bare minimum operations required to satisfy the concept). So I think this would be clearer if we simply test with a bidirectional iterator and omit the static_assert
s and the comment.
ad861c8
to
0be3474
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Almost LGTM! Let's figure out whether we can pass the number of comparisons as a parameter to test cases, and this will be ready to go from my perspective.
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for addressing the feedback! Really minor comments -- I expect to LGTM in the next round.
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
...s/iterator.primitives/range.iter.ops/range.iter.ops.advance/iterator_count_sentinel.pass.cpp
Outdated
Show resolved
Hide resolved
…a struct for 'check_backwards'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks a lot for preparing this patch and addressing all the feedback! The Android failure on the CI looks like an infrastructure issue -- I'll go ahead and merge this.
Currently, the bounds check in
std::ranges::advance(it, n, s)
is done beforen
is checked. This results in one extra, unneeded bounds check.Thus,
std::ranges::advance(it, 1, s)
currently is not simply equivalent to:if (it != s) { ++it; }
This difference in behavior matters when the check involves some "expensive" logic. For example, the
==
operator ofstd::istreambuf_iterator
may actually have to read the underlyingstreambuf
.Swapping around the checks in the
while
results in the expected behavior.