What is New here? - aka 4 ways or creating structs

In this post I will investigate 4 different approaches for creating structs and I will measure their performance characteristics.

The release of C# 10 removed a previous restriction on struct constructors: structs are allowed have default constructors. This means the code below compiles without a build error.

public struct Person
{
    public int Age { get; set; } = 1;

    public string Name { get; set; } = "Foo";
}

In this case the default constructor will set the property values as 1 and Foo respectively. This behavior is really similar to classes. The 4 ways this post explores creating structs:

  1. Using the new keyword

  2. Using the default keyword

  3. Using generics and new() constraint

  4. Using generics and struct constraint

The rest of the post shows the code sample for each option, compares performance from mean execution time and memory point of view, and finally looks into the IL/JITted assembly code to reason about the performance findings. For microbenchmarks the BenchmarkDotNet library is in .NET6 and Windows 11 platform.

Using the new keyword

public Person New()
{
  return new Person();
}

This code returns a new Person with the default constructor invoked, so the struct returned has the Name property set to "Foo" and the Age property to 1.

Using the default keyword

public Person Default()
{
  return default(Person);
}

This method uses the default keyword to create the Person struct. The default constructor will not execute and all the fields are set to 0 or null, whether it is a value or reference type. In case of the Person struct, the Age is set to 0, and the Name is null.

Using generics and new() constraint

A generic solution is often needed in case serialization and deserialization. A library performing the serialization might not presume the type of the source or target object, hence needs to use a generic approach.

public Person GenericNew()
{
  return CreateNew<Person>();
}

private T CreateNew<T>() where T : new()  => new();

In this case the code is structured into two methods: a generic CreateNew<T> method has a new() constraint which means that the type must have a public parameterless constructor: new T().

For creating the generic object, C# compiler emits a call to Activator.CreateInstance, which creates a new instance of the type.

IL_0000: call !!0 [System.Runtime]System.Activator::CreateInstance<!!T>()

Using generics and struct constraint

public Person GenericDefault()
{
  return CreateDefault<Person>();
}

private T CreateDefault<T>() where T : struct => default;

The code is still structured into two methods, but now the generic CreateDefault<T>() uses a struct constraint to enforce, that only structs may be used as generic type parameters. It allows to use the default keyword to get the struct created, while not allowing nulls for reference types. This the C# compiler uses the same initobj IL instruction as the non-generic method to create the struct.

The next section of this post compares these methods from performance point of view.

Benchmarks

These benchmarks are captured using BenchmarkDotNet on .NET6, Windows 11 Build 21H2 on x64 platform with x64 RyuJIT.

|         Method |       Mean |     Error |    StdDev |     Median |  Gen 0 | Allocated |
|--------------- |-----------:|----------:|----------:|-----------:|-------:|----------:|
|        Default |  0.0002 ns | 0.0007 ns | 0.0011 ns |  0.0000 ns |      - |         - |
|            New |  1.6940 ns | 0.0689 ns | 0.1225 ns |  1.7064 ns |      - |         - |
|     GenericNew | 14.8469 ns | 0.3227 ns | 0.7544 ns | 14.6591 ns | 0.0102 |      32 B |
| GenericDefault |  0.0393 ns | 0.0278 ns | 0.0803 ns |  0.0000 ns |      - |         - |

The Default and GenericDefault cases are close to be equivalent of an empty method call, mostly because they don't need to do anything, just return memory that is zero-ed out. The New method takes somewhat longer, but note, that the default constructor of the struct executes this time, setting the Age and Name properties. So far these cases don't cause any memory allocation on the heap, which is desired in case of using structs. However, GenericNew performance lies out as it both allocates memory on the heap and executes significantly slower to the other methods.

Let's observer the JITted assembly code to reveal the inner workings.

Jitted Code

Default and GenericDefault

As previously pointed out, Default and GenericDefault use the same IL instructions. After optimizations and inlining, the runtime emits identical assembly code:

       xor       eax,eax
       mov       [rdx],rax
       mov       [rdx+8],eax
       mov       rax,rdx
       ret
; Total bytes of code 12

In the first instruction 0 is set in the eax register. Then the two fields (Name and Age) are overwritten with this value.

New assembly code

; Benchmarks.New()
       push      rsi
       mov       rsi,rdx
       mov       rdx,276D3CDA038
       mov       rdx,[rdx]
       mov       rcx,rsi
       call      CORINFO_HELP_CHECKED_ASSIGN_REF
       mov       dword ptr [rsi+8],1
       mov       rax,rsi
       pop       rsi
       ret
; Total bytes of code 37

The size of the code is more than 3 times larger in bytes compared to Default and GenericDefault methods. This is because it needs to execute the code for the default constructor. For the Name property the "Foo" string has to be loaded and set (which involves also executing a write barrier), while for the Age property the value 1 is written to the rsi+8 address.

GenericNew assembly code

Finally, the largest code is required for the GenericNew method.

; Benchmarks.GenericNew()
       mov       rcx,rdx
       jmp       near ptr System.Activator.CreateInstance[[Person, StructConstructor]]()
; Total bytes of code 8
; System.Activator.CreateInstance[[Person, StructConstructor]]()
       push      rdi
       push      rsi
       push      rbx
       sub       rsp,20
       mov       rbx,rcx
       mov       rcx,offset MT_Person
       call      CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
       mov       rcx,rax
       call      System.RuntimeType.CreateInstanceOfT()
       mov       rsi,rax
       mov       rdx,offset MT_Person
       cmp       [rsi],rdx
       je        short M01_L00
       mov       rdx,rsi
       mov       rcx,offset MT_Person
       call      CORINFO_HELP_UNBOX
M01_L00:
       add       rsi,8
       mov       rdi,rbx
       call      CORINFO_HELP_ASSIGN_BYREF
       movsq
       mov       rax,rbx
       add       rsp,20
       pop       rbx
       pop       rsi
       pop       rdi
       ret
; Total bytes of code 94

Most of the code is required for implementing Activator.CreateInstance method. It needs to load the type handle for Person. It needs to create the given instance for the type: the call to System.RuntimeType.CreateInstanceOfT() will instantiate a new object and call its default constructor. It also needs to unbox the returned object, which involves further runtime type checking. All these extra operations result in a performance cost compared to the other methods. Because the object gets created on the heap, it accounts for the generation 0 allocations in the benchmark results. Unboxing involves further copies, the data, making this method ultimately the slowest performing solution.

Conclusion

C# 10 makes struct creation mentally more involved, as developers have to keep track the different ways and outcomes of struct creation. A rather unfortunate outcome is the performance trap of using generics with new() constraint and structs, which results in a performance penalty compared to the other options presented in this post.