Rust vs C++: A Performance Comparison

Dmytro Gordon
Rustaceans
Published in
8 min readDec 25, 2023

Despite the provocative title, the aim of this article is not to find a winner but rather to explore how various elements in the design of each language can impact performance and the reasons behind that. I was inspired by Henrique Bucher’s post, where some of the statements may sound contentious but offer a valuable perspective on how these two languages might appear to someone prioritizing performance.

So let’s begin. Both languages can be used for systems programming, offering a low-level access to memory and even share the same compiler backends — LLVM and GCC (although still experimental for Rust). At first glance, we can wisely assert “it is the developer who matters, not the language”. And this can even be true, but there are some good points to mention…

Aliasing

It’s a well-known fact that by default, C++ compilers treat pointers of the same type as potentially referencing the same or overlapping data. For instance, consider these two functions:

#include <cstddef>
#include <cstdint>

void mul_by_2(const uint64_t* src, uint64_t* dst, size_t count) {
for (size_t i = 0; i < count; ++i) {
dst[i] = 2 * src[i];
}
}

void mul_by_2_restrict(const uint64_t* __restrict src, uint64_t* __restrict dst, size_t count) {
for (size_t i = 0; i < count; ++i) {
dst[i] = 2 * src[i];
}
}

These functions produce different assembly. The __restrict specifier tells the compiler “hey, this pointer references unique data,” allowing it to generate a more optimized assembly. Although __restrict is not a C++ keyword, all mainstream compilers support it. performance-conscious C++ developer is aware of it and uses it at least for code segments critical to performance. However, it doesn’t seem to be an elegant solution. You are forced to extract the pointers out of all the containers because you can’t convince the compiler that the pointer inside unique_ptr or vector doesn’t overlap with another. That really makes the code uglier. Even more ugly than the performance-critical C++ code usually is.

Unlike C++, Rust’s borrowing rules ensure that in safe code, all mutable references point to unique objects. You don’t need any extra qualifiers, and everything works well (I do know it’s not idiomatic Rust, but let’s keep it closer to C++) for the following code:

pub fn mul_by_2(src: &[u64], dst: &mut[u64]) {
for i in 0..src.len() {
dst[i] = 2 * src[i]
}
}

The disparity in the generated assembly between the C++ version using __restrict and the Rust version arises from the necessity of the safe Rust function to verify array bounds. Just a note: the example above is quite artificial, in a real case just use iterators to avoid unnecessary bounds checks.

However, once you step into the realm of unsafe code, this isn’t the case anymore. The compiler treats every pointer as potentially overlapping with another.:

pub unsafe fn mul_by_2(src: *const u64, dst: *mut u64, count: usize) {
for i in 0..count {
*dst.add(i) = 2 * *src.add(i)
}
}

This results in outcomes similar to C++ without the __restrict specifier. But there are no analogs to the __restrict qualifier in unsafe Rust whatsoever! The only available option is to use a workaround by obtaining pointers from the slices within the function's scope:

pub unsafe fn mul_by_2(src_slice: &[u64], dst_slice: &mut [u64], count: usize) {
let src: *const u64 = src_slice.as_ptr();
let dst: *mut u64 = dst_slice.as_mut_ptr();
for i in 0..count {
*dst.add(i) = 2 * *src.add(i)
}
}

This does work fine, but… surely it is not a normal way! What if I don’t want to change the signature?! This leaves me no choice but to artificially create another unsafe function, form slices from pointers, and pass them where I can then convert them into pointers that the compiler will treat as independent. I encourage readers to share better workarounds in the comments, if any exist, and I’ll incorporate them here.

In conclusion, safe Rust beats C++ in terms of aliasing. But when it comes to unsafe, Rust just gives you no help and leaves the burden of inventing workarounds solely on your shoulders.

Move semantics and “move” semantics

Back in 2011, a new C++11 standard introduced several huge language improvements, and move semantics was one of them. It radically changed the way of passing objects to functions and returning them. Since then, you can transfer ownership of a large object (string, vector, map, etc.) without worrying about it being deep-copied. Prior to this, one could only rely on Return Value Optimization (RVO) to potentially eliminate copies when returning values in specific scenarios. Finally, you got your new shine unique_ptr, which would be meaningless without move semantics. The scoped_ptr beast was finally consigned to oblivion.

But you know… It's not quite a move semantics. I would honestly name it “you-can-steal-my-resources semantics”, as it is just another type of reference with some benefits from the compiler. In real life when you move your table from one room to another, you end up with one less table in the initial room. But in the C++ world when you call std::move the old object is still there, and you can access it!

#include <vector>

void consume(std::vector<int> data) {}

void f() {
std::vector<int> data{1, 2 , 3};
consume(std::move(data));

data.resize(10);
// and also it will be destructed here
}

Beyond the risk of accessing an object in an improper state and the philosophical discrepancy with real-world scenarios, this approach also impacts performance. Retaining the old object after a move operation means the compiler must handle its proper destruction, leading to overhead. I highly recommend watching Chandler Carruth’s talk on the potential overhead associated with the usage of unique_ptr, if you haven’t already.

Screenshot from the vide

(screenshot from the video)

As you might have noticed, when the code was adjusted by carefully adding the noexcept specifier and using rvalue references the resulting assembly appeared to be almost the same at the cost of readability and simplicity.
That’s the result of the requirement to retain the old object after a move.

In Rust, you won’t find something similar to std::move in C++. Instead, when an object is moved in Rust, it’s just copied as a sequence of bytes. I remember my confusion when learning Rust after many years of writing C++.

“What!?? Where are my old friends — copy constructor, move constructor, generic copy/move constructors, assignment operator, move assignment operator? Who do you think you are to say that copying bytes is what I want?”

However, it turns out that when your object is passed by value, you don’t care about what is happening behind the scenes (unless you start thinking about self-referential structures, but in the Rust world map, you have only Pin and dragons there). And that allows the compiler to perform better optimizations without carrying about all those objects that were left behind. It’s a significant leap forward, made possible because the language was designed without the constraints of backward compatibility.

Dynamic dispatch

Another interesting point about C++ and Rust is that dynamic dispatch is implemented in different ways.

In C++ if the type has any methods that can be called virtually (including those of its ancestors), a pointer to the virtual table is added to every instance of that type.

Image from https://www.equestionanswers.com/cpp/vptr-and-vtable.php

The table itself exists in a single instance per type and contains pointers to the function implementations (thunks) for all virtual functions. That is a pretty widespread solution, carrying its own set of advantages and drawbacks.

Pros:

  • A single pointer to the object contains all the information for a virtual call
  • Beyond function pointers, the vtable object can be expanded to include data about type members, facilitating runtime reflection. While C++ doesn’t fully utilize this potential, many other languages employing similar virtual dispatch strategies do.

Cons:

  • Even if you don’t use dynamic dispatch for a certain type, every object of that type becomes larger by a pointer size.
  • This scheme makes it impossible to implement some interface for a class defined in the external library.
  • Performing a virtual function call involves a double indirection: object pointer -> vtable pointer -> function pointer. Although the impact on performance is debatable.

Rust doesn’t have a concept of virtual methods and object (structure) inheritance. Instead, it introduces traits (that can be thought of as interfaces for both dynamic and static dispatch) and dynamic objects. In Rust, the implementation of a trait for a certain type isn’t embedded within the type itself. This allows, for example, implementing traits for the types defined in other crates (following the orphan rule, of course). And this is the reason why the dynamic dispatch implementation is different. Every type instance doesn’t contain extra data beyond the members. However, when a dynamic object is passed, it involves passing two pointers under the hood: a pointer to the data and a pointer to the vtable containing the trait method implementations for the type (+drop method).

This offers an alternate approach, bringing along its own set of advantages and drawbacks.

Pros:

  • You don’t pay for the dynamic dispatch if you don’t use it for the type.
  • Implementation of a trait for an external type is feasible.
  • One less indirection level for calling a function via dynamic dispatch.

Cons:

  • You need one extra pointer every time you pass a dynamic object.

It is hard to say which of the approaches is strictly better than the other one. However, it is good to know both, because in certain cases, you can emulate the virtual dispatch implementation with another approach if it better suits your needs. And sometimes you can even see the familiar bits in the standard library.

A really comprehensive video on this topic already exists on YouTube and is strongly recommended to watch.

And there are more topics

In the next part, we will take a look at the memory layout.

Hey Rustaceans!

Thanks for being an awesome part of the community! Before you head off, here are a few ways to stay connected and show your love:

  • Give us a clap! Your appreciation helps us keep creating valuable content.
  • Become a contributor! ✍️ We’d love to hear your voice. Learn how to write for us.
  • Stay in the loop! Subscribe to the Rust Bytes Newsletter for the latest news and insights.
  • Support our work!Buy us a coffee.
  • Connect with us: X

--

--