⚠️ Just as a disclaimer: Please be aware that the shown performance differences are super minor. That said don't run through your code and change everything to a struct. Microsoft recommends classes by default for a reason. If you have a supercritical hot path, then it might be justified.
When we are passing objects around we can do this either via reference or by value. Which of those two methods is faster?
To answer this question we have to dive a bit info what happens exactly when you pass something around and how the other side will receive this.
Passing by value
A value type like int
is passed to a method by value. That means a copy of that object is made and passed down to the caller. How? We just put the new copy onto the stack frame the calling function can see. By the way, if you are not familiar with the term stack, value type or reference type I'd advise you to have a look at my blog post about that.
Best explained with this simple example
int a = 0;
Change(a); // We pass a copy of a to the function
Console.Write(a); // Prints still 0
void Change(int a) => a = 5; // The function changes the copy to 5
Passing by reference
Technically speaking pass by reference does not really exist. Everything is passed by value. When we say pass by reference we mean pass the value of the reference to the callee. If we have a look at the previous example we find a slight change in the outcome:
int a = 0;
Change(ref a); // We pass the reference of a to the function
Console.Write(a); // Prints 5
void Change(ref int a) => a = 5; // The function changes the "original a" to 5
I guess this part is well known. But it is also not the whole truth. To understand what and why something is faster we have to go a bit deeper.
Dereferencing
Now just passing a reference to a function is only one part. We also have to dereference that value in order to read it. So we can see there are two operations involved when it comes down to pass by reference. Remember a reference is basically a memory address. That is all!
- Put the reference onto the stack frame so that the callee can access it
- Dereference and read the real content
The interesting part is (2), because dereferencing happens every time we access that variable inside our code:
void DoSomething(ref MyStruct myStruct)
{
int x = myStruct.Prop1; // Dereferencing and reading 4 bytes
int y = myStruct.Prop2; // Dereferencing and reading 4 bytes
Size of a reference
I said earlier we are copying the value of the memory address of a reference type to the callee. In C/C++ (as well as C#) those references to memory addresses are called pointers. Pointers are (generally) either 4 bytes wide on a 32-bit process or 8 bytes wide on 64-bit process. That is no coincidence. That means copying this value can be done inside a single register in your CPU. Important to know is that this is super fast and basically the best case.
struct
s and their size
Now maybe you get an idea where this is all going to. We saw earlier that size is a very very important factor. (Spoiler Alert: Also layout plays a role, more to that later). So when we pass a struct by its value, the size is the key factory which determines if it is faster or not. Remember we have less work to do with passing by value than passing by reference. Only if we have to copy way more stuff we will be slower. Have a look at this struct:
public struct Point2D
{
public int X { get; set; }
public int Y { get; set; }
}
This is a struct
that has two int
's. Each int is 32 bits respectively 4 bytes wide. That means all in all the struct
is exactly 64-bit wide.
So the exact same size as our pointer when we pass references around. But in contrast to a reference, after copying we are done. The callee doesn't have to dereference anything.
Benchmark
Here is a small benchmark to show this in the wild.
public class PassByBenchmark
{
private readonly PointClass pointClass = new();
private readonly PointStruct pointStruct = new();
[Benchmark(Baseline = true)]
public int GetSumViaClassReference()
{
var sum = 0;
for(var i = 0; i < 20_000; i++)
sum += GetSumClass(pointClass);
return sum;
}
[Benchmark]
public int GetSumViaStruct()
{
var sum = 0;
for(var i = 0; i < 20_000; i++)
sum += GetSumStruct(pointStruct);
return sum;
}
[Benchmark]
public int GetSumViaStructReference()
{
{
var sum = 0;
for(var i = 0; i < 20_000; i++)
sum += GetSumRefStruct(in pointStruct);
return sum;
}
}
private int GetSumClass(PointClass c) => c.X + c.Y;
private int GetSumStruct(PointStruct c) => c.X + c.Y;
private int GetSumRefStruct(in PointStruct c) => c.X + c.Y;
}
public class PointClass
{
public int X { get; set; }
public int Y { get; set; }
}
public struct PointStruct
{
public int X { get; set; }
public int Y { get; set; }
}
Results:
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|------------------------- |---------:|---------:|---------:|------:|--------:|
| GetSumViaClassReference | 12.94 us | 0.259 us | 0.318 us | 1.00 | 0.00 |
| GetSumViaStruct | 12.05 us | 0.229 us | 0.214 us | 0.92 | 0.03 |
| GetSumViaStructReference | 12.82 us | 0.244 us | 0.365 us | 0.99 | 0.04 |
Passing a class by reference or a struct by reference makes no difference at all. That is expected as the same mechanism are in place. But we can see that our struct Point
is the fastest method for the sole reason it is small and copying this struct has the same impact as copying the pointer to our callee.
Fat structure
Now this will look very different when our structure is a bit wider. Let's have the same setup but we "refactor" our struct and class as follows:
public class PointClass
{
public int A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z;
}
public struct PointStruct
{
public int A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z;
}
Our structure is now 4 byte * 26 letters = 104 byte wide. Our reference is still only 8 bytes(in a 64 bit process).
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|------------------------- |----------:|---------:|---------:|------:|--------:|
| GetSumViaClassReference | 13.17 us | 0.140 us | 0.131 us | 1.00 | 0.00 |
| GetSumViaStruct | 109.50 us | 2.161 us | 4.058 us | 8.28 | 0.40 |
| GetSumViaStructReference | 13.22 us | 0.253 us | 0.260 us | 1.00 | 0.02 |
We can see that the runtime of our references are still the same as expected. But our struct
is 8x slower. The only thing that changed is the bigger copy we have to make.
StructureLayout
One last part which can be intersting is the layout of a struct. We are already in the rabbit hole so why not going a bit deeper 😉. Have a look at those two structures:
public struct Struct1
{
public int Number1;
public bool HasNumber1;
public int Number2;
public bool HasNumber2;
}
public struct Struct2
{
public int Number1;
public int Number2;
public bool HasNumber1;
public bool HasNumber2;
}
Are they the same? Yes and no. Sure they have the same properties. But the layout is way different:
Taken from here
Even though we have the same amount of properties the layout of the struct is different. What happens here is that a struct will be filled in 4-byte blocks (this value is depending on your platform). That means if we start with a bool
we only have 3 bytes left, therefore an int
can not fit anymore and 3 bytes have to be padded to fill the block. This can have performance implications but more important that can lead to big issues if you have an interop with other (native) languages like C/C++. In this article, I will not go deeper into that matter as it can get rather complicated. Maybe something for the future 😉
Conclusion
I hope you have a better understanding of what happens when you pass something by reference and by value. And why structs can be faster than classes or ref structs.