Fun with SkipLocalsInit

The next version of C# might support the SkipLocalsInit attribute. This attribute is an indicator to Roslyn (the C# compiler) to avoid emitting the localsinit flags for the annotated methods.

This new feature can only be used with the /unsafe compiler switch - and as we shall see, with good reason.

The CLR supports a .localsinit attribute for methods, which, when set guarantees that all variables are zeroed out before running the method. For example, if the following were legal C#, you would consistently observe 0 as the result.

void GiveMeTheLocal()
{
    int something;
    Console.WriteLine(something)
}

However it's not legal C# because C# goes one step further and checks for definite assignment. Definite Assignment Analysis is a process which verifies that all variables are assigned before being used. For example when compiling the above, I get CS0165 C# Use of unassigned local variable. This helps me avoid mistakes as a developer as I should never really want to use variables before assigning them, even if it's a small matter of writing int something = default to make things clear.

The C# team have in recent years been prioritising the performance of the language to promote C# as a serious competitor for high performance applications. (e.g. in and readonly structs). For example, game developers need pedal to the metal speed to provide a smooth user experience, and thus look for any opportunity to remove inefficiencies. So it is unsurprising that some see the localsinit flag as an unnecessary shackle to performance, especially when considering that C# has Definite Assignment built-in and thus even if locals were not zeroed, they would still be overwritten most of the time.

So SkipLocalsInitAttribute was born to instruct Roslyn to avoid emitting .localsinit for performance critical methods. The biggest gain here is for methods with a large stack frame, therefore especially for methods using stackalloc which allocates an array on the stack. The CLR zeroes out this array if .localsinit is set, and if the array is large and the program is about to assign every slot in the array anyway, it's totally wasteful.

So what kind of evil code does this new option allow? Certainly, we can be unimaginative and create a method which keeps an invocation count on the stack using stackalloc. Let's fire up VS2019 preview with a /unsafe project and code something.

static void Main(string[] args)
{
    for (int i = 0; i < 10; i++)
    {
        PrintRunningCount();
    }
}

[SkipLocalsInit]
static void PrintRunningCount()
{
    const int Magic = 123214;
    Span<int> arr = stackalloc int[2];
    if (arr[0] != Magic)
    {
        arr[0] = Magic;
        arr[1] = 0;
    }
    Console.WriteLine($"Hello World stackalloc {arr[1]++}!");
}

// Output:
// Hello World stackalloc 0!
// Hello World stackalloc 1!
// Hello World stackalloc 2!
// Hello World stackalloc 3!
// Hello World stackalloc 4!
// Hello World stackalloc 5!
// Hello World stackalloc 6!
// Hello World stackalloc 7!
// Hello World stackalloc 8!
// Hello World stackalloc 9!

We can see that every time PrintRunningCount is invoked, it creates an array which holds 2 values. The first arr[0] is a magic value to keep track of whether the method is being run for the first time. Since localsinit is no longer there to help, we can't assume our counter starts from 0 (and indeed it does not in my tests). The second value holds the actual count.

This example is pretty safe if you ask me; the developer has opted in to using [SkipLocalsInit] and thus can rely on definite assignment except for stackalloc. However now we move into the more unsafe territory... to when Definite Assignment Analysis fails us! The rules are pretty clear when it comes to variables being assigned as a whole (variable = ...). However structs are special and can be assigned piecewise, as in the following example.

struct StackCounter
{
    public long A;
    
    public static long Increment(ref StackCounter c) { }
}

[SkipLocalsInit]
void PrintRunningCountStruct()
{
    StackCounter counter
    counter.A = default;
    // counter is definitely assigned since we've set all of it's fields

    var count = StackCounter.Increment(ref counter);

    Console.WriteLine($"Hello World {count}!");
}

Compile the above, and as expected you will observe Hello World 0! printed a few times on the console. However this rule for definite assignment has a problem. What if one were to move StackCounter to another library and compile the console program as-is? Definite Assignment is checked at compilation time and thus the console program is successfuly compiled.

What if one were to then "update" the library, recompile it, and overwrite the old dll file? This kind of thing happens often - for example one project might reference and older version of a library in the same solution as a project referencing a new version, and when everything gets built, the newer one is used for both. In my case, I "updated" StackCounter as follows.

public struct StackCounter
{

    public long A;
    public long Count;
    public long Magic;

    public static long Increment(ref StackCounter c)
    {
        const long Magic = 23872139472367;
        if (c.Magic != Magic)
        {
            c.Magic = Magic;
            c.Count = 0;
        }

        return c.Count++;
    }

}

If we run the same program now, we see that the count does increment from Hello World 0! to Hello World 9!. For one looking at the source, this example is a much less clearly broken than the stackalloc example. It is the library developer who now stores state on the stack of another method.

Such a library change is of course backwards-incompatible and the whole situation is a bit contrived to imagine library creators to abuse this (there are easier and more predictable ways), and when recompiling against the newer version, Captain Definite Assignment will save the day.


Other posts you might like


Join the Discussion

You must be signed in to comment.

No user information will be stored on our site until you comment.