I'm using BenchmarkDotNet to benchmark struct related code, and noticed that the performance of my benchmark depends on the number of parameters my struct contains.
[MemoryDiagnoser]
public class Runner
{
[Params(1000)]
public int N;
[Benchmark]
public void StructKey()
{
var dictionary = new Dictionary<BoxingStruct, int>(); //only difference
for (int i = 0; i < N; i++)
{
var boxingStruct = MakeBoxingStruct(i);
if (!dictionary.ContainsKey(boxingStruct))
dictionary.Add(boxingStruct, i);
}
}
[Benchmark]
public void ObjectKey()
{
var dictionary = new Dictionary<object, int>(); //only difference
for (int i = 0; i < N; i++)
{
var boxingStruct = MakeBoxingStruct(i);
if (!dictionary.ContainsKey(boxingStruct))
dictionary.Add(boxingStruct, i);
}
}
public BoxingStruct MakeBoxingStruct(int id)
{
var boxingStruct = new BoxingStruct()
{
Id = id,
User = new UserStruct()
{
name = "Test User"
}
};
return boxingStruct;
}
}
public struct BoxingStruct
{
public int Id { get; set; }
public UserStruct User { get; set; }
public override bool Equals(object obj)
{
if (!(obj is BoxingStruct))
return false;
BoxingStruct mys = (BoxingStruct)obj;
return mys.Id == Id;
}
public override int GetHashCode()
{
return Id;
}
}
public struct UserStruct
{
public string name { get; set; }
}
public class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<Runner>();
}
}
This simple benchmark creates structs and adds them to a dictionary if the dictionary doesn't already contain them. The only difference between StructKey() and ObjectKey() is the key type of the Dictionary, one being a BoxingStruct and the other an object. In this example my UserStruct only has one field in it. If I run that I achieve the following results:
| Method | N | Mean | Allocated |
|---------- |----- |---------:|----------:|
| StructKey | 1000 | 54.85 us | 128.19 KB |
| ObjectKey | 1000 | 61.50 us | 162.32 KB |
Now if I add several more elements to the UserStruct, my performance results flip.
public struct UserStruct
{
public string name { get; set; }
public string email { get; set; }
public string phone { get; set; }
public int age { get; set; }
}
public BoxingStruct MakeBoxingStruct(int id)
{
var boxingStruct = new BoxingStruct()
{
Id = id,
User = new UserStruct()
{
name = "Test User",
email = "testemail@gmail.com",
phone = "8293839283",
age = 11110,
}
};
return boxingStruct;
}
Results:
| Method | N | Mean | Allocated |
|---------- |----- |----------:|----------:|
| StructKey | 1000 | 112.00 us | 213.2 KB |
| ObjectKey | 1000 | 90.97 us | 209.2 KB |
Now the StructKey method takes more time and allocates more memory. But I don't know why? I've run this multiple times and running with 8 and 16 parameters gives similar results.
I've read up on the differences between structs and objects, value v. reference type. With structs the data is copied but objects just pass items by reference. String is a reference type so I'm fairly certain that isn't stored on the stack. That stacks have limited storage capacity, but I don't think I'm getting close to that. By have the dictionary key be an object am I boxing the value type?
All those things being said, whatever the performance differences are between the two dictionary's, I would expect the number of struct parameters not to change which method is more performant. I would gladly appreciate if anyone can elaborate what is going on that influences the performance of these benchmarks.
I'm on a windows machine running dotnet core 2.2.300, running benchmarks in release mode, here is a Github repo containing my benchmark.
EDIT
I implemented both IEquatable and IEqualityComparer, performance actually got worse and the same relationship still exists. With 1 property StructKey() is faster and uses less memory, while with 4 properties ObjectKey() is faster and uses less memory.
public struct BoxingStruct : IEqualityComparer<BoxingStruct>, IEquatable<BoxingStruct>
{
public int Id { get; set; }
public UserStruct User { get; set; }
public override bool Equals(object obj)
{
if (!(obj is BoxingStruct))
return false;
BoxingStruct mys = (BoxingStruct)obj;
return Equals(mys);
}
public bool Equals(BoxingStruct x, BoxingStruct y)
{
return x.Id == y.Id;
}
public bool Equals(BoxingStruct other)
{
return Id == other.Id;
}
public override int GetHashCode()
{
return Id;
}
public int GetHashCode(BoxingStruct obj)
{
return obj.Id;
}
}
1 Property Result:
| Method | N | Mean | Allocated |
|---------- |----- |---------:|----------:|
| StructKey | 1000 | 62.32 us | 128.19 KB |
| ObjectKey | 1000 | 71.11 us | 162.32 KB |
4 Properties Result:
| Method | N | Mean | Allocated |
|---------- |----- |---------:|----------:|
| StructKey | 1000 | 155.5 us | 213.29 KB |
| ObjectKey | 1000 | 109.1 us | 209.2 KB |
As both Hans and Ivan alluded to in the comments I was overlooking the value typeness of using structs. There are two main categories of types in C#, reference types and value types.
When a reference type is created, a local variable points to a memory location on the heap where the object is stored. When you pass a reference type to a method, only the reference is passed around while the object on the heap remains there.
When a value type is created, it is stored on the stack. When passing a value type to a method an entire copy of that value type is made and that copy is passed to the method.
Obviously the more data the struct has the more data that needs to be copied any time it moves. Explaining why my struct benchmark performed worse as it grew larger.