Where do standard library or compilers leverage noexcept move semantics (other than vector growth)?
Asked Answered
F

2

13

Move operations should be noexcept; in the first place for intuitive and reasonable semantics. The second argument is runtime performance. From the Core Guidelines, C.66, "Make move operations noexcept":

A throwing move violates most people’s reasonably assumptions. A non-throwing move will be used more efficiently by standard-library and language facilities.

The canonical example for the performance-part of this guideline is the case when std::vector::push_back or friends need to grow the buffer. The standard requires a strong exception guarantee here, and this can only move-construct the elements into the new buffer if this is noexcept - otherwise, it must be copied. I get that, and the difference is visible in benchmarks.

However, apart from this, I have a hard time finding real-world evidence of the positive performance impact of noexcept move semantics. Skimming through the standard library (libcxx + grep), we see that std::move_if_noexcept exists, but it's almost not used within the library itself. Similarly, std::is_noexcept_swappable is merely used for fleshing out conditional noexcept qualifiers. This doesn't match existing claims, for example this one from "C++ High Performance" by Andrist and Sehr (2nd ed., p. 153):

All algorithms use std::swap() and std::move() when moving elements around, but only if the move constructor and move assignment are marked noexcept. Therefore, it is important to have these implemented for heavy objects when using algorithms. If they are not available and exception free, the elements will be copied instead.

To break my question into pieces:

  1. Are there code paths in the standard library similar to the std::vector::push_back, that run faster when fed with std::is_nothrow_move_constructible types?
  2. Am I correct to conclude that the cited paragraph from the book is not correct?
  3. Is there an obvious example for when the compiler will reliably generate more runtime-efficient code when a type adheres to the noexcept guideline?

I know the third one might be a bit blurry. But if someone could come up with a simple example, this would be great.

Fribble answered 3/3, 2021 at 14:35 Comment(3)
If your move constructor may throw, you should be able to justify that with strong arguments. Not the other way around.Interglacial
Well I absolutely agree with the guideline. But it seems that the semantics aspect is far more important than the performance one.Fribble
I've found a couple inaccuracies in that book myself, so it's not a very reliable resource. I agree that the semantics aspect is the important part. Functions that take by value and move are usually marked noexcept and a throwing move constructor would break that.Interglacial
L
11

Background: I refer to std::vector's use of noexcept as "the vector pessimization." I claim that the vector pessimization is the only reason anyone ever cared about putting a noexcept keyword into the language. Furthermore, the vector pessimization applies only to the element type's move constructor. I claim that marking your move-assignment or swap operations as noexcept has no "in-game effect"; leaving aside whether it might be philosophically satisfying or stylistically correct, you shouldn't expect it to have any effect on your code's performance.

Let's check a real library implementation and see how close I am to wrong. ;)

  • Vector reallocation. libc++'s headers use move_if_noexcept only inside __construct_{forward,backward}_with_exception_guarantees, which is used only inside vector reallocation.

  • Assignment operator for variant. Inside __assign_alt, the code tag-dispatches on is_nothrow_constructible_v<_Tp, _Arg> || !is_nothrow_move_constructible_v<_Tp>. When you do myvariant = arg;, the default "safe" approach is to construct a temporary _Tp from the given arg, and then destroy the currently emplaced alternative, and then move-construct that temporary _Tp into the new alternative (which hopefully won't throw). However, if we know that the _Tp is nothrow-constructible directly from arg, we'll just do that; or, if _Tp's move-constructor is throwing, such that the "safe" approach isn't actually safe, then it's not buying us anything and we'll just do the fast direct-construction approach anyway.

Btw, the assignment operator for optional does not do any of this logic.

Notice that for variant assignment, having a noexcept move constructor actually hurts (unoptimized) performance, unless you have also marked the selected converting constructor as noexcept! Godbolt.

(This experiment also turned up an apparent bug in libstdc++: #99417.)

  • string appending/inserting/assigning. This is a surprising one. string::append makes a call to __append_forward_unsafe under a SFINAE check for __libcpp_string_gets_noexcept_iterator. When you do s1.append(first, last), we'd like to do s1.resize(s1.size() + std::distance(first, last)) and then copy into those new bytes. However, this doesn't work in three situations: (1) If first, last point into s1 itself. (2) If first, last are exactly input_iterators (e.g. reading from an istream_iterator), such that it's known impossible to iterate the range twice. (3) If it's possible that iterating the range once could put it into a bad state where iterating the second time would throw. That is, if any of the operations in the second loop (++, ==, *) are non-noexcept. So in any of those three situations, we take the "safe" approach of constructing a temporary string s2(first, last) and then s1.append(s2). Godbolt.

I would bet money that the logic controlling this string::append optimization is incorrect. (EDIT: yes, it is.) See "Attribute noexcept_verify" (2018-06-12). Also observe in that godbolt that the operation whose noexceptness matters to libc++ is rv == rv, but the one it actually calls inside std::distance is lv != lv.

The same logic applies even harder in string::assign and string::insert. We need to iterate the range while modifying the string. So we need either a guarantee that the iterator operations are noexcept, or a way to "back out" our changes when an exception is thrown. And of course for assign in particular, there's not going to be any way to "back out" our changes. The only solution in that case is to copy the input range into a temporary string and then assign from that string (because we know string::iterator's operations are noexcept, so they can use the optimized path).

libc++'s string::replace does not do this optimization; it always copies the input range into a temporary string first.

  • function SBO. libc++'s function uses its small buffer only when the stored callable object is_nothrow_copy_constructible (and of course is small enough to fit). In that case, the callable is treated as a sort of "copy-only type": even when you move-construct or move-assign the function, the stored callable will be copy-constructed, not move-constructed. function doesn't even require that the stored callable be move-constructible at all!

  • any SBO. libc++'s any uses its small buffer only when the stored callable object is_nothrow_move_constructible (and of course is small enough to fit). Unlike function, any treats "move" and "copy" as distinct type-erased operations.

Btw, libc++'s packaged_task SBO doesn't care about throwing move-constructors. Its noexcept move-constructor will happily call the move-constructor of a user-defined callable: Godbolt. This results in a call to std::terminate if the callable's move-constructor ever actually does throw. (Confusingly, the error message printed to the screen makes it look as if an exception is escaping out the top of main; but that's not actually what's happening internally. It's just escaping out the top of packaged_task(packaged_task&&) noexcept and being halted there by the noexcept.)


Some conclusions:

  • To avoid the vector pessimization, you must declare your move-constructor noexcept. I still think this is a good idea.

  • If you declare your move-constructor noexcept, then to avoid the "variant pessimization," you must also declare all your single-argument converting constructors noexcept. However, the "variant pessimization" merely costs a single move-construct; it does not degrade all the way into a copy-construct. So you can probably eat this cost safely.

  • Declaring your copy constructor noexcept can enable small-buffer optimization in libc++'s function. However, this matters only for things that are (A) callable and (B) very small and (C) not in possession of a defaulted copy constructor. I think this describes the empty set. Don't worry about it.

  • Declaring your iterator's operations noexcept can enable a (dubious) optimization in libc++'s string::append. But literally nobody cares about this; and besides, the optimization's logic is buggy anyway. I'm very much considering submitting a patch to rip out that logic, which will make this bullet point obsolete. (EDIT: Patch submitted, and also blogged.)

I'm not aware of anywhere else in libc++ that cares about noexceptness. If I missed something, please tell me! I'd also be very interested to see similar rundowns for libstdc++ and Microsoft.

Lido answered 5/3, 2021 at 20:5 Comment(3)
That's a very detailed analysis, unfortunately a bit depressing :) Thanks!Fribble
This answer seems to focus on benefits of noexcept from the point of view of the library explicitly checking for it to allow for optimizations. But what about the generated code from the compiler generally? Will the presence of noexcept allow the compiler to generate more compact code, e.g. omitting boilerplate for exception propagation?Judaist
@dpacbach: quuxplusone.github.io/blog/2022/07/30/… is relevant to your interests. Basically, noexcept can indeed be useful for optimizations if it is placed at an ABI boundary — a function pointer, virtual function, or function whose body isn't visible to the caller — and then the function is tail-called from a noexcept context (such as a destructor). In practice I don't think ordinary programmers need to be concerned with that, though; when noexcept matters, it is newsworthy because it is surprising.Lido
I
4

vector push_back, resize, reserve, etc is very important case, as it is expected to be the most used container.

Anyway, take look at std::fuction as well, I'd expect it to take advantage of noexcept move for small object optimization version.

That is, when functor object is small, and it has noexcept move constructor, it can be stored in a small buffer in std::function itself, not on heap. But if the functor doesn't have noexcept move constructor, it has to be on heap (and don't move when std::function is moved)

Overall, there ain't too many cases indeed.

Iago answered 3/3, 2021 at 14:52 Comment(1)
Now, that's a good example I didn't know about.Interglacial

© 2022 - 2024 — McMap. All rights reserved.