Removing allocations by reducing closure scopes using local variables

 
 
  • Gérald Barré

Lambdas can use variables from the enclosing methods (documentation), unless the lambda is static. When a variable is captured, the compiler generates code to create the bridge between the enclosing method and the lambda. Let's consider this simple example:

C#
public void M()
{
    var a = 0;

    // Create a lambda that use "a" from the enclosing method
    var func = () => Console.WriteLine(a);
    func();
}

When compiling the code, the compiler rewrites the code as the following:

C#
// Class to store all captured variables and the code of the lambda
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
    // The variable "a" is captured
    public int a;

    // () => Console.WriteLine(a)
    internal void <M>b__0() => Console.WriteLine(a);
}

public void M()
{
    <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
    <>c__DisplayClass0_.a = 0;
    new Action(<>c__DisplayClass0_.<M>b__0)();
}

Let's take another example. The 2 following methods are very similar, except the second one creates a local variable in the else block.

C#
public void M1(object? o)
{
    if (o == null)
    {
        Console.WriteLine("null");
    }
    else
    {
        Task.Run(() => Console.WriteLine(o));
    }
}

public void M2(object? o)
{
    if (o == null)
    {
        Console.WriteLine("null");
    }
    else
    {
        var scoped = o;
        Task.Run(() => Console.WriteLine(scoped));
    }
}

Both methods will do the same thing. But the second one, M2, is more performant than the first one in the case the value is null. The difference is the way the compiler rewrites the code. The compiler captures the variable at the beginning of the scope where it is declared. In this case the variable is a parameter, and so, is accessible everywhere in the method. So, the compiler captures the variable at the beginning of the method.

In the second method, the captured variable is not o but scoped. This variable is only available in the else block, so the compiler needs to capture the variable in the else block, and not at the beginning of the method.

The main difference is when o is null, the class doesn't need to be instantiated, so you can avoid one allocation.

C#
public void M1(object? o)
{
    // Allocated even when o is null
    <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
    <>c__DisplayClass0_.o = o;
    if (<>c__DisplayClass0_.o == null)
    {
        Console.WriteLine("null");
    }
    else
    {
        Task.Run(new Action(<>c__DisplayClass0_.<M1>b__0));
    }
}

public void M2(object? o)
{
    if (o == null)
    {
        Console.WriteLine("null");
        return;
    }

    // Allocated only when o is not null
    <>c__DisplayClass1_0 <>c__DisplayClass1_ = new <>c__DisplayClass1_0();
    <>c__DisplayClass1_.scoped = o;
    Task.Run(new Action(<>c__DisplayClass1_.<M2>b__0));
}

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub