Struct Equality and SkipLocalsInit
02/23/2025
4 minutes
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 struct
s 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 paddingallocate 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.