Skip to content

[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

Merged
merged 10 commits into from
Apr 2, 2024

Conversation

jiixyj
Copy link
Contributor

@jiixyj jiixyj commented Mar 6, 2024

Currently, the bounds check in std::ranges::advance(it, n, s) is done before n 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 of std::istreambuf_iterator may actually have to read the underlying streambuf.

Swapping around the checks in the while results in the expected behavior.

@jiixyj jiixyj requested a review from a team as a code owner March 6, 2024 06:59
@llvmbot llvmbot added the libc++ libc++ C++ Standard Library. Not GNU libstdc++. Not libc++abi. label Mar 6, 2024
@llvmbot
Copy link
Member

llvmbot commented Mar 6, 2024

@llvm/pr-subscribers-libcxx

Author: Jan Kokemüller (jiixyj)

Changes

Currently, the bounds check in std::ranges::advance(it, n, s) is done before n 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 of std::istreambuf_iterator may actually have to read the underlying streambuf.

Swapping around the checks in the while results in the expected behavior.


Full diff: https://github.com/llvm/llvm-project/pull/84126.diff

1 Files Affected:

  • (modified) libcxx/include/__iterator/advance.h (+2-2)
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;
         }

@jiixyj
Copy link
Contributor Author

jiixyj commented Mar 6, 2024

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 operator== calls on stride_counting_iterator?

@ldionne ldionne added the ranges Issues related to `<ranges>` label Mar 7, 2024
@var-const var-const self-assigned this Mar 8, 2024
Copy link

github-actions bot commented Mar 9, 2024

✅ With the latest revision this PR passed the C/C++ code formatter.

@jiixyj
Copy link
Contributor Author

jiixyj commented Mar 9, 2024

I added some tests that check that the equality comparison operator isn't called unnecessarily by adding another counter to stride_counting_iterator.

Copy link
Member

@var-const var-const left a 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.

assert(it.equals_count() == 0);
} else {
assert(it.equals_count() > 0);
assert(it.equals_count() == M || it.equals_count() == M + 1);
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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
Copy link
Member

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?

Copy link
Contributor Author

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:

{
// 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".

Copy link
Member

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_asserts and the comment.

Copy link
Contributor Author

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 a std::bidirectional_iterator
  • that S (the sentinel type) is the same as It

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?

Copy link
Contributor Author

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.

Copy link
Member

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);
Copy link
Member

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
Copy link
Member

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_asserts and the comment.

@jiixyj jiixyj requested a review from var-const March 29, 2024 10:06
@jiixyj jiixyj force-pushed the ranges-advance-swap-checks-around branch from ad861c8 to 0be3474 Compare March 29, 2024 10:32
Copy link
Member

@var-const var-const left a 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.

@jiixyj jiixyj requested a review from var-const April 1, 2024 10:39
Copy link
Member

@var-const var-const left a 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.

@jiixyj jiixyj requested a review from var-const April 2, 2024 07:05
Copy link
Member

@var-const var-const left a 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.

@var-const var-const merged commit 84ae8cb into llvm:main Apr 2, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
libc++ libc++ C++ Standard Library. Not GNU libstdc++. Not libc++abi. ranges Issues related to `<ranges>`
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants