Why Rust easily optimizes out smart pointers where C++ cannot?
I think that this example can be both entertaining and educating. Smart pointers are often used in high-performance code (like browser engines) so it is interesting to learn how their usage impacts the generated code.
Here is a simple function that adds two numbers using smart pointers. Rust compiles it into two instructions [1], while C++ creates about 100 instructions [2] including unnecessary memory locks.
I wonder, is there a way to improve C++ code so that the smart pointers get optimized out? Did I make some mistake that prevents optimization?
61 comments
[ 4.1 ms ] story [ 132 ms ] threadAlso, if one makes add_numbers public, the generated code is much larger. I don't know why the compiler chooses not to inline it in this case, probably (really speculating here) because it can be called in multiple locations and the IR for the function is over the inlining budget.
[1]: https://doc.rust-lang.org/src/alloc/sync.rs.html#1088
Meanwhile in Rust if someone added the same kind of reference then the compiler would force it to be immutable (trying to create a mutable reference would cause a compiler error). Mutating the field would require that some part of the code own the value, not just own an Rc with a reference to the value inside of it. In Rust you can have mutability _or_ you can have multiple aliases, but not both at the same time. Thus the Rust compiler is free to make optimizations the C++ compiler cannot.
(But you’re right about the specific program here; it’s too simple for aliasing and mutability to really matter; I was just answering the general question.)
Also, if someone passed this Rc along to another thread then they would also get a compile error. If they really need to do that then they can make the jump to the Arc type, which is thread–safe, and then the compiler will allow it. But they can never make the mistake of sending an un–thread–safe value to another thread where it will cause race conditions, data corruption, etc.
Humans are fallible, but compilers don’t forget.
https://godbolt.org/z/r3bh3s3Y5
The best we could expect from this example is for the C++ compiler to deduce that the shared_ptr is irrelevant because the callee has internal linkage.
add_numbers() doesn't need ownership of the data, so you would never give it ownership of the data. If you happen to have the data in a reference-counted type you would get the data from it (https://en.cppreference.com/w/cpp/memry/unique_ptr/get / https://en.cppreference.com/w/cpp/memory/shared_ptr/get), and pass it to add_numbers(). The data would still be guaranteed to exist because the caller of add_numbers() is going to hold a reference at least until add_numbers() returns.
See https://stackoverflow.com/a/58340952.
Everything in C++ is zero cost, until it's not...oops!
But you don’t have to follow anyone’s ABI inside your own code do you?
In general they assume object at a time compiler (in the compiler sense, not types), and different objects may come from other compilers, instances, or even languages. So they use the standard ABI.
For an internal function they don’t have to use the standard ABI as by definition all users of that function are from within the current object. In such cases compilers can optimize the calling convention. I recall that msvc at least did this - but in those cases they’re free to change anything - they can in-line and not include the function at all in the resultant object. That’s why you often see “static inline”
Haven't used much C++ recently, so I don't know if the standard library has a good single-thread implementation these days, but it's not "too hard" to roll your own if performance is critical - it may not be as good as just two instructions, but it should be much leaner than shared_ptr.
The C++ language standard folks had to make a different choice. For historical reasons the C++ compiler is not able to prevent the program author from misusing a non–thread–safe smart pointer by sending it to another thread, so all smart pointers are required to be thread–safe. The compiler may try to optimize away the extra locking, and it may or may not succeed in any given program. It may not be able to prove that it is safe to do so even when you as the author know that it will be.
Incidentally, every Rust program comes with a free test runner which is multithreaded; it runs multiple tests in parallel. This trips people up from time to time because the compiler will insist that certain types be thread–safe (tagged with the Send trait, in Rust) even though the author is not actually using any threads.
I take a slightly different view in the statics thing; like a static means you want something to be available across threads. So by using a static, you’re opening yourself to that possibility. If you want something global to a thread, use thread local storage, and Rust will allow you to use stuff that’s not okay if multiple threads were involved. It’s about expressing your intent correctly.
I opened a bug about this on the rust repo. [1]
[1] https://github.com/rust-lang/rust/issues/97751
There is no equivalent in the standard C++ lib. Though it's very easy to write one. If you use gcc's libstdc++, the internal shared_ptr is actually templated on the lock policy so you can do something like: template<typename T> using nt_shared_ptr = std::__shared_ptr<T, __gnu_cxx::_S_single>;
Then if you use nt_shared_ptr instead, the code will be much simpler.
That said, your C++ code is incorrect as well. It gives the ownership to the shared ptr of a stack variable. During the destruction of the shared_ptr, delete will be called on a stack address leading to undefined behavior
e.g nt_shared_ptr<Data> x(&d, [](Data*) -> void{});
https://godbolt.org/z/d3qv4cE1v
https://www.boost.org/doc/libs/1_65_0/libs/smart_ptr/doc/htm...
That's 4 instructions only for add_numbers(), but add_two_numbers_smart_way() still is unoptimized.
A better question would be why C++ went for implementing certain things on the library level instead of the compiler level, and the answer here is probably "historical reasons" and someone's judgement call on flexibility vs performance under specific circumstances.
Does rust try to follow the platform C ABI? C++ implementations typically do when it is semantically meaningful.
Rust doesn’t let you utilize this fact on stable yet, which is what you’re thinking of, but the implementation in this case already exists.
* There will be not more than 1 reference to the object at any time.
* The object will be deleted once the reference dies.
That's a zero-overhead GC that is enforced at compile time.
And that is the main appeal of C++. You can get close-to-optimal instruction-level behavior while still describing things at reasonably high levels of abstraction. Yes, it has a hell of a learning curve, but once you master it, you can beat any other language out there.
It does give you non-ambiguous ownership semantics which is a good thing but there's still plenty of ways to misuse/abuse it.
That said if you can make the aliasing guarantees(such as Rust's borrow checker does) that opens up the possibility of automatically applying restrict semantics[1] which enables further set of optimizations available to the compiler.
[1] https://en.cppreference.com/w/c/language/restrict
Also, I definitely would not call std::unique_ptr (or Box) a GC.
Another one is what happens when you move a value out, in C++ it’s in an “indeterminate state”, but in Rust you can’t access a value after move. Which means that you can get a null pointer exception in C++, but not in Rust, and means that destructors for uniq_ptr have to check for this case, whereas in Rust they don’t.
Rust defaulting to move-semantics and requiring explict copies is one of the best decisions in the language.
You should check out Rust's concept of ownership. The goodness of ownership that is implemented in unique_ptr in C++ exists for every type in Rust.
Untrue:
>That's a zero-overhead GC that is enforced at compile time.It's not zero overhead unfortunately. Because unique_ptr is not trivially movable or trivially destructible, there are numerous optimizations that every current C++ compiler does not perform, for example being able to pass a unique_ptr in a register.
unique_ptr is reasonable given C++'s limitations, but it doesn't come close to being as good as Rust's Box, which is genuinely zero overhead and also guarantees that there's only 1 mutable reference to the object at any given time.
This is also happening in the Rust code (x.clone()).
I can't demonstrate it because godbolt is utterly broken on mobile, but passing unique_ptr reference is the clean way.
Importantly, you can just pass raw pointers or references around because adding objects does not semantically modify them or their ownership.
Also, your Rust example uses -O (which is -C opt-level=2), but your C++ example uses Clang's -O3 (which is like rustc's -C opt-level=3).
Here are examples which are more similar: Rust: https://godbolt.org/z/We7ah1ehe C++: https://godbolt.org/z/r31jrcbjc
> Smart pointers are often used in high-performance code (like browser engines) so it is interesting to learn how their usage impacts the generated code.
Browsers like Firefox and Chrome don't use std::shared_ptr. You should research how the compiler optimizes those implementations.