C++ emplace_back vs Vec::push: moving?

I was reading this on emplace_back vs push_back in C++. I was pretty sure in-place construction was not done or possible in Rust. So I tried to code up the example used in that blog, and it seems that when I do push to a Vec the (conceptual) temporary Test struct is not destructed after being moved into Vec. Is Vec::push() compiled down to the equivalent of C++ emplace_back(..) when possible?

See playground below:

struct Test(String); // Not copy

impl Test {
    pub fn new() -> Test {
        println!("construct");
        Test(String::new())
    }
}

impl Drop for Test {
    fn drop(&mut self) {
        println!("drop");
    }
}

fn main() {
    println!("Hello, world!");

    {
        let t = Test::new();
    }

    let mut v1 = Vec::with_capacity(2);
    v1.push(Test::new());

    println!("finish");
}

(Playground)

The Rust temporary is not dropped because Rust has destructive move semantics, compared to C++ where moves still leave a valid object behind (though temporaries may sometimes be elided). In Rust, that temporary value ceases to exist after it has been moved. This can even go as far as needing local drop flags, like if you move a local value in a conditional branch, then it tracks whether a drop is needed when you didn't take that branch.

14 Likes

Thanks! So no memory is copied from stack to heap in this case, i.e. the temporary is constructed in its final location on first go?

It is not (necessarily). The point is that a value must only ever have exactly one owner. If the temporary was also dropped, it would be a double free.

1 Like

Ok. So the memory (value?) might be copied to the new location as part of the push, but it is not necessary to call drop on the original memory since the new location has ownership of the value (excuse the flaky terminology: The pointer to the contents of the String is copied along, but should obviously not be dropped from the temporary. The new location String frees it when it is dropped.

Anyway, so Rust does not then do in-place construction (like emplace)? Or at least my experiment does not really show me anything about that.

Yes.

It's not guaranteed, but optimizations still apply. Never in real life did I encounter a case where push() not being guaranteed emplace()-like lead to a deterioration of performance.

1 Like

That is a true statement as far as it goes. However, the reality is a bit more nuanced. Rust can optimize moves better than C++ because they are destructive. But it does not have guaranteed, automatic copy elision in any particular scenario.

In practice this is rarely a problem, and in cases where a copy must not occur you can always do the C++ thing by hand with raw pointers (at the expense of giving up the other advantages of destructive move). But there is a bit of a gap and there have been suggestions about "placement syntax", as well as C++ style sometimes-guaranteed-copy-elision that would cover this kind of thing. But it's difficult to come up with a set of guarantees that is acceptable to add to Rust currently, doesn't require major changes to existing code, and is actually useful in real world scenarios. If you can find some of the old RFCs about placement syntax, those discussions cover a lot of ground.

7 Likes

Thanks a lot. This clears things up. I too have not seen any real performance issue with this, but it seems to be more real (though not very) in C++. That could perhaps partly be due to extra destruction, and slightly different move.

To do in-place construction I would have to do something like this: Unchecked - The Rustonomicon ? I could create a new_in_place(self: MaybeUninit<Self>) constructor.

In-place construction is rarely necessary in Rust. You’d need to provide a concrete use-case and show significant improvements in a benchmark to justify anything more complicated than just using .push if the more complicated approach involves unsafe code.

The article gives little convincing motivation AFAICT even for why to use emplace_back over push_back in C++ (for types that have move-constructors).

There’s this

With the simple benchmark here, we notice that emplace_back is 7.62% faster than push_back when we insert 1,000,000 object (MyClass) into an vector.

argument, but they neither describe how they measured it nor whether they removed the printing from the move constructor. Of course, if the thing they’re measuring is this printing behavior

--- push_back ---
Create Class
Move Constructor Called
Destroy Class

--- emplace_back ---
Create Class

then printing more will take more time, unsurprisingly.

Moving values in Rust is generally even faster than move-constructors in C++, because Rust’s static analysis means it doesn’t need to

  • change the original object into a “null”-like state
  • call the destructor of the moved-out-of object

I’m convinced there are good/important use cases for emplace_back, just the linked article doesn’t really seem to explain them. One use-case might be types that don’t support moving. In Rust all types support moving, so that’s never a problem. Another use-case is types that are of rather large size. In this case the copy from the stack into the vector can be significant overhead, or even lead to stack overflows, and in-place construction could solve the problem. You shouldn’t really run into this though, unless you’re creating something like a Vec<[T; 10000]>. E.g. your test example uses a String, moving a String just moves 3 words, that’s really cheap. If you do run into this case, it can be a bit annoying to handle/solve in Rust at the moment. As far as I’m aware, I think finding really good (safe) solutions for supporting in-place construction are still an open design space?

5 Likes

No, that moves too, since you are taking the wrapped value by-value.

2 Likes

Thanks. I agree, the benchmarking in the linked article is unconvincing, and the benefit for this tiny struct (especially with a String that needs its data on the heap somewhere) is unlikely. It helped me understand what was going on in the move. I am trying to understand this so that I understand how memory is initialized on a low level, and how moves work, in Rust. This is how vec! does things, but using box syntax, as far as I can see. I do some embedded-programming at the moment, and memory and CPU speed is limited, though I doubt I will actually need to do in-place construction (have not needed to use an allocator yet).

Yes, you are right. But if I passed a raw pointer or a &mut MaybeUninit it would be a way to structure the code in a sensible way for in-place construction.

My main question is if documentation and code in Unchecked-uninit is the safest way to do this. It does not dereference or try to drop any uninitialized memory.

I think it's pretty complex what could happen.

  1. return the entire struct in return registers, then caller copies into vector-heap
  2. copy struct to return call stack, then caller reads from call stack and copies onto vector-heap
  3. caller passes target struct memory location, callee initializes directly to target location, nothing returned
  4. code gets inlined; init happens to pure-registers, then get copied to vector-heap
  5. code gets inlined; but uses spill variables on call stack (e.g. you spill BEFORE alloating the struct fields as registers) - so while the emplace happens with no intermediate memory write, you have an EXTRA spill/reload for the extra lexicals that didn't fit in the register file for your outer function

Note the above can happen no matter the move/copy symantics of the compiler - as these optimizations happen down-stream of the intermediate-representation-code.

But note one thing that never happens, for either C++ or Rust.. The String or Vec initialized heap data stays in place. E.g. if your Test::new allocated 900 bytes, that would not be copied. And performance wise, this is really all that matters.

The only situation I think it matters is when the entire function is called in a loop can be converted into a single memset call out into the heap (thus via AVX/SSE you can init multiple loop iterations per memory write).

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.