1

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 |
Morgan Kenyon
  • 3,072
  • 1
  • 26
  • 38
  • Sure, boxing a larger struct takes more time to move the data and more memory to store the data. A dictionary that uses a struct type as the key should use IEqualityComparer<> to avoid that cost. Check [this Q+A](https://stackoverflow.com/questions/46142734/why-is-hashsetpoint-so-much-slower-than-hashsetstring) for a very similar case. – Hans Passant Jun 04 '19 at 01:31
  • I agree with that, better to take the Struct type instead of the object. But do you know why performance would change depending on the number of struct parameters? Is there just less to copy? – Morgan Kenyon Jun 04 '19 at 01:33
  • I read through the linked QA, in my example would the same equals method be called from both Dictionary's? In the linked QA the asker is actually creating two different objects in his difference examples, one string and the other point. Whereas I'm creating the same struct type in both cases. Also I'm not sure if that would explain why for 1 parameter vs. 4 parameters which benchmark is faster? – Morgan Kenyon Jun 04 '19 at 01:40
  • It is the overhead of having to box to call GetHashCode and Equals that matters. Dictionary may have to so multiple times to deal with hash collisions. Boxing it early by making the key of type *object* hides that overhead. The ideal case is to not have to box at all, that requires IEqualityComparer. – Hans Passant Jun 04 '19 at 02:00
  • Does that overhead change depending on the number of properties on the struct? I get that boxing in general negatively affects performance. I'm not understanding why in these two examples boxing with 1 property is faster than boxing with 4 properties. – Morgan Kenyon Jun 04 '19 at 02:05
  • Boxing requires allocating data to store the object and copying the data of the struct into that object. More data requires more time, it is as simple as that. – Hans Passant Jun 04 '19 at 02:07
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/194402/discussion-between-morgan-kenyon-and-hans-passant). – Morgan Kenyon Jun 04 '19 at 02:08
  • @HansPassant I implemented both interfaces and the weird relationship still exists. – Morgan Kenyon Jun 04 '19 at 03:08
  • The references still take some space (4 or 8 bytes depending on the 32/64 bit-ness). Adding more fields to the struct simply means more bytes to copy. Note that the copy happens for each call (`ContainsKey`, `Add`) as well for the method calls they do internally. – Ivan Stoev Jun 04 '19 at 11:04
  • @IvanStoev After implementing iEqualityComparator and IEquatable, in my StructKey() example I believe that there shouldn't be any more boxing/unboxing. If that's true, then StructKey() shouldn't incur any performance hit and I would expect it to be better than ObjectKey(). Would you agree with that assessment? – Morgan Kenyon Jun 05 '19 at 01:55
  • @IvanStoev Or is it since structs are value types every call to `ContainsKey` and `Add` copies the whole struct. So it's not a boxing/unboxing issue but just copying lots of data issue? – Morgan Kenyon Jun 05 '19 at 02:38
  • Indeed. Copying the struct content when passing it to a method is the main overhead of structs. And the main driver for introducing [in parameter modifier](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/in-parameter-modifier) in C#7.2 – Ivan Stoev Jun 05 '19 at 06:53
  • @IvanStoev Just to confirm my understanding, in ObjectKey(), when the struct is inserted into the dictionary it's boxed to type object, which is stored on the heap. Therefore `ContainsKey` and `Add` doesn't have to copy when comparing because it's already a reference type and not a value type. If I kept adding parameters to the struct, 100, 300, would you expect the performance to get worse and worse compared to the object? – Morgan Kenyon Jun 05 '19 at 13:15

1 Answers1

1

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.

Morgan Kenyon
  • 3,072
  • 1
  • 26
  • 38