Struct Equality and SkipLocalsInit

I have recently learned an interesting behavior of the .NET runtime on equality from a post on Mastodon. The post points out that RuntimeHelpers.Equals returns whether the input arguments' underlying memory are equal, which may not be true always, even when the values are equal.

Under the hood RuntimeHelpers.Equals uses memcmp to compare the memory representation of two objects.

Padding

In C# structs may have paddings in the memory representation. For example, the following struct will have a padding on x64, so that the value is aligned to 8 bytes:

struct MyType
{
    public short Id;
    public double Value;
}

In this sharplab.io example the padding can be viewed with the help of the Inspect command:

Id

Padding

Value

01 00

00 00 00 00 00 00

00 00 00 00 00 00 00 E0 3F

Notice, that replacing the short type of the Id field with double would results a value without padding:

Id

Value

00 00 00 00 00 00 F0 3F

00 00 00 00 00 00 00 E0 3F

There are other ways to create structs with paddings, for example by controlling the struct layout. The exact behavior of paddings depends on the processor architecture, the type of fields, reference types, etc.

For the purpose of this post the important fact is that MyType has a padding with the short Id field.

SkipLocalsInit

SkipLocalsInit is an attribute well-explained in this post by meziantou, and I have also previously explored it in the context of array allocations.

This flag tells the JIT compiler to skip initializing local variables to their default value of 0. That means memory remains uninitialized, and it may show invalid values. To enable skipping locals initialization, one needs to apply the attribute (method, assembly, etc. level) and turn on AllowUnsafeBlocks compiler option.

With MyType when there is no value set explicitly for the Id or Value fields, they may take up a random value instead of being zeroed out, for example when being stack allocated. But the padding remains uninitialized even when the ctor of the type runs.

This example in sharplab shows that the padding part of the struct remains uninitialized.

Equality

The interesting behavior can be achieved by combining the above two behaviors:

  • enable SkipLocalsInit

  • add a struct type with padding

  • allocate two values of this type and set their fields to be equal

  • test equality RuntimeHelpers.Equals

[module: SkipLocalsInit]
var a = new MyType() { Id = 1, Value = 0.5 };
Span<MyType> c = stackalloc MyType[2]; // Needs to be >1
c[0].Id = 1;
c[0].Value = 0.5;
Console.WriteLine(a.Equals(c[0]));
Console.WriteLine(RuntimeHelpers.Equals(a, c[0]));

The above example prints True False. When the equality is tested by the regular Equals call, the struct types will perform a field-by-field comparison. Because all fields are initialized to the same value this operation results true. When RuntimeHelpers.Equals performs the comparison (structs become boxed) the underlying memory is different due to the uninitialized padding. Hence this operation returns false.

Conclusion

While I find the above behavior interesting, I don't think regular LOB applications would face such situations where developers would need to worry about it. However, for libraries that are designed to solve high-performance or interop problems, custom structs and struct layouts with skipping locals initialization is more common. In such a case it is good to be aware of the above-described behavior.