DEV Community

Aleksandar Milenkovic
Aleksandar Milenkovic

Posted on

C++ | Optimizing Move Until We Cannot

Decl Motivated and inspired by Decl. In development and soon to be open-sourced.

C++ | Optimizing Move Until We Cannot

For the video overview of this week, let's go with C++ Optimizing Move and Destructor Semantics and optimize the C++ move semantics until it is not possible to optimize any more.

Consider the following code:

std::vector<S> v;

S s = S(1);

v.push_back(std::move(s));
Enter fullscreen mode Exit fullscreen mode

This code produces the following C++ RAII output:

S(int) // OK! This is a just constructor
S(S&&) // OVERHEAD! Move involved in order to "move" the entity into the destination memory region of the vector item
~S()   // OVERHEAD! Destructor invoked for the rvalue at the end of the scope
~S()   // OK! Destructor invoked by the "~std::vector<S>()"
Enter fullscreen mode Exit fullscreen mode

The question here is "Why would the moved value even be eligible for a destructor if it is completely dumped or unused?" - the obvious answer is that C++ is required to take care of all the hanging objects once they get out of scope.

The output above is also the case even for a prvalue such as:

std::vector<S> v;

v.push_back(S(1));
Enter fullscreen mode Exit fullscreen mode

It is also important to note that these overheads are not optimized at deeper optimization levels such as -O1, -O2, -O3. This is purely a C++ thing.

This is because the std::vector::push_back accepts an T&& value as the argument, which needs to be std::moved eventually and, inevitably.

For example,

void push_back(T&& item) {
  new(&buffer[stack_count]) T(std::move(item));
  stack_count++;
}
Enter fullscreen mode Exit fullscreen mode

For a standard library that is a std::vector, this is done via the allocator constructor so it does essentially come down to the move constructor.

template<typename... _Args>
void construct(pointer __p, _Args&&... __args)
{
  ::new((void *)__p) _Tp(std::forward<_Args>(__args)...);
}
Enter fullscreen mode Exit fullscreen mode

To fully understand the scope of this issue and in an attempt to narrow it down, we must implement something similar to the std::vector container given that we don't have the full control over a container like the std::vector.

We are gonna use the simple static storage implementation from the C++ | Static Storage Implementation video.

The usage of it is as simple as the following:

StaticStorage<S, 3> list;

list.add(S(1));
Enter fullscreen mode Exit fullscreen mode

Just like that, we have manage to replicate the same C++ RAII flow:

S(int) // OK! This is a just constructor
S(S&&) // OVERHEAD! Move involved in order to "move" the entity into the destination memory region of the vector item
~S()   // OVERHEAD! Destructor invoked for the prvalue at the end of the expression - the semi colon
~S()   // OK! Destructor invoked by the "~StaticStorage()"
Enter fullscreen mode Exit fullscreen mode

The first thing to do here is not to have a function at all since the function in C++ needs additional work like std::move to cast it back to the original type given that:

void add(T&& item) {
  // Any time we use item, it is an lvalue so std::move(item) is required!
}
Enter fullscreen mode Exit fullscreen mode

The closing thing to a function that avoids the standard function arguments is, of course, a macro - particularly the statement expression one.

So if we turn the add function into a macro, we get something like the following:

#define _storage_add(storage, item, T) ({ \
    new(&storage.buffer[storage.stack_count * sizeof(T)]) item; \
    storage.stack_count++; \
})
Enter fullscreen mode Exit fullscreen mode

Now, if we do something like this:

StaticStorage<S, 3> list;

_storage_add(list, S(12), S)
Enter fullscreen mode Exit fullscreen mode

This produces the following C++ RAII:

S(int) // OK! This is a just constructor
~S()   // OK! Destructor invoked by the "~StaticStorage()"
Enter fullscreen mode Exit fullscreen mode

The code, when replaced, at the preprocessor level doesn't apply any moves whatsoever:

It is a direct new placement so any overheads like move are optimized out since they are not necessary for the construction of an object.

new(&storage.buffer[storage.stack_count * sizeof(S)]) S(12);
Enter fullscreen mode Exit fullscreen mode

We have successfully optimized out the unnecessary move - but for a prvalue after all.

What about the rvalue that we need to move out of the scope and, still, eliminate the move and the destructor invocation for the moved value that is, of course, considered "dumped", "unused", or "redundant" at that point so why have the bottleneck of C++ RAII?

Let's say there is some work that we need to do on the s before it becomes a part of the container buffer - either pushed or added in any way.

StaticStorage<S, 3> list;

S s = S(1);

*s.i_ptr += n + n1; // Work with the s

// We are ready to add it onto our buffer, but it still needs to be moved!
_storage_add(list, S(std::move(s)), S);
Enter fullscreen mode Exit fullscreen mode

We might have gotten a couple of steps forward, but we are back to square one with the following output:

S(int) // OK! This is a just constructor
S(S&&) // OVERHEAD! Move involved in order to "move" the entity into the destination memory region of the vector item
~S()   // OVERHEAD! Destructor invoked for the rvalue at the end of the scope
~S()   // OK! Destructor invoked by the "~StaticStorage()"
Enter fullscreen mode Exit fullscreen mode

There is one more trick we can pull out of our sleeve: that is the function copy elision.

In C++, returning a [temporary] local object by value often avoids any copy or move operations thanks to copy elision (also known as (Named) Return Value Optimization). The compiler constructs the object directly in the caller's storage, effectively keeping the original object instead of creating a temporary copy.

So if we wrap up the work that needs to be done on your instance,

inline S make_s(int n, int n1) __attribute__((always_inline));

inline S make_s(int n, int n1) {
  S s = S(n + n1);

  *s.i_ptr += n;

  // Copy Elision
  return s;
}
Enter fullscreen mode Exit fullscreen mode

Please, note that we made the function inline-able and we are even forcing the inlining to each of its references in the code. This is done due to the fact that we must avoid the CALL overhead that function calls produce in our assembly. The call overhead involves push + pop + ret so it is definitely not something we can afford.

We are not using a lambda here either as it not a guarantee that all of the lambda calls will be inlined by the compiler.

Without further ado, let's add this onto our buffer storage!

StaticStorage<S, 3> list;

_storage_add(list, S(make_s(n, n1)), S);
Enter fullscreen mode Exit fullscreen mode

This, syntactically, looks like the new placement will apply just another copy or move - however, THAT IS NOT THE CASE!

The output of C++ RAII becomes:

S(int) // // OK! This is a just constructor
~S()  // OK! Destructor invoked by the "~StaticStorage()"
Enter fullscreen mode Exit fullscreen mode

This is because if the function return type in C++ is non-reference type (by value or pointer), the return value of the expression becomes the prvalue so the compiler effectively optimizes this as:

new(&storage.buffer[storage.stack_count]) S(/* Determined at runtime by make_s */);
Enter fullscreen mode Exit fullscreen mode

This behavior is identical to the following:

struct S {
    S(int x) { /* ... */ }

    // Testing it with copy and move deleted:
    S(const S&) = delete;
    S(S&&) = delete;
};

S s = make_s( ... ); // C++17+ Guaranteed Copy Elision. No copy or move. 
// Similar to
S s = S(1); // Direct Construction. Guaranteed no copy or move since C++17.
Enter fullscreen mode Exit fullscreen mode

With that being our final optimization, we have reached the breaking point and the hard stop, thusly, we are not able to optimize it any further!

Conclusion and YouTube Video

Just like with every other video and overview of mine, you should apply these "black-magic" optimizations only under specific circumstances and for particular use cases that truly require such aggressive optimization techniques — not in your general code. That said, studying them can still sharpen your understanding of C++.

Keep in mind that this is just a brief overview of the YouTube video! For the full experience with all the details we missed here, check out my YouTube channel and C++ Optimizing Move and Destructor Semantics.

For the source code of this overview and the video, check out my GitHub repository at https://github.com/BeAllAround/S2S-Compilers-Blog-Series-Public.

Top comments (0)