Removing allocations by reducing closure scopes using local variables

 
 
  • Gérald Barré

Lambdas can capture variables from enclosing methods (documentation), unless the lambda is static. When a variable is captured, the compiler generates code to bridge the enclosing method and the lambda. 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 this code, the compiler rewrites it as follows:

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)();
}

Consider another example. The two following methods are functionally identical, except the second 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 produce the same result, but M2 is more efficient when the value is null. The difference lies in how the compiler rewrites the code. The compiler captures a variable at the beginning of the scope where it is declared. Since o is a parameter, it is accessible throughout the entire method, so the compiler captures it at the top of the method.

In M2, the captured variable is scoped rather than o. Since scoped is only accessible within the else block, the compiler captures it there instead.

The key difference: when o is null, the display class is never instantiated in M2, saving one heap 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?