Understanding the cost of C# delegates

Paulo

Delegates are widely used in C# (and .NET, in general). Either as event handlers, callbacks, or as logic to be used by other code (as in LINQ).

Despite their wide usage, it’s not always obvious to the developer what delegate instantiation will look like. In this post, I’m going to show various usages of delegates and what code they generate so that you can see the costs associated with using them in your code.

Explicit instantiation

Throughout the evolution of the C# language, delegate invocation has evolved with new patterns without breaking the previously existing patterns.

Initially (versions 1.0 and 1.2), the only instantiation pattern available was the explicit invocation of the delegate type constructor with a method group:

delegate void D(int x);

class C
{
    public static void M1(int i) {...}
    public void M2(int i) {...}
}

class Test
{
    static void Main() {
        D cd1 = new D(C.M1);        // static method
        C t = new C();
        D cd2 = new D(t.M2);        // instance method
        D cd3 = new D(cd2);         // another delegate
    }
}

Implicit conversion

C# 2.0 introduced method group conversions where an implicit conversion (Implicit conversions) exists from a method group (Expression classifications) to a compatible delegate type.

This allowed for short-hand instantiation of delegates:

delegate string D1(object o);

delegate object D2(string s);

delegate object D3();

delegate string D4(object o, params object[] a);

delegate string D5(int i);

class Test
{
    static string F(object o) {...}

    static void G() {
        D1 d1 = F;            // Ok
        D2 d2 = F;            // Ok
        D3 d3 = F;            // Error -- not applicable
        D4 d4 = F;            // Error -- not applicable in normal form
        D5 d5 = F;            // Error -- applicable but not compatible

    }
}

The assignment to d1 implicitly converts the method group F to a value of type D1.

The assignment to d2 shows how it is possible to create a delegate to a method that has less derived (contravariant) parameter types and a more derived (covariant) return type.

The assignment to d3 shows how no conversion exists if the method is not applicable.

The assignment to d4 shows how the method must be applicable in its normal form.

The assignment to d5 shows how parameter and return types of the delegate and method are allowed to differ only for reference types.

The compiler will translate the above code to:

delegate string D1(object o);

delegate object D2(string s);

delegate object D3();

delegate string D4(object o, params object[] a);

delegate string D5(int i);

class Test
{
    static string F(object o) {...}

    static void G() {
        D1 d1 = new D1(F);            // Ok
        D2 d2 = new D2(F);            // Ok
        D3 d3 = new D3(F);            // Error -- not applicable
        D4 d4 = new D4(F);            // Error -- not applicable in normal form
        D5 d5 = new D5(F);            // Error -- applicable but not compatible

    }
}

As with all other implicit and explicit conversions, the cast operator can be used to explicitly perform a method group conversion. Thus, this code:

object obj = (EventHandler)myDialog.OkClick;

will be converted by the compiler to:

object obj = new EventHandler(myDialog.OkClick);

This instantiation pattern might create a performance issue in loops or frequently invoke code.

This innocent looking code:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
Sort(lines, StringComparer.OrdinalIgnoreCase.Compare);
...

Will be translated to:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
Sort(lines, new Comparison<string>(StringComparer.OrdinalIgnoreCase.Compare));
...

Which means that an instance of the delegate will be created on every invocation. A delegate instance that will have to be later collected by the garbage collector (GC).

One way to avoid this repeated instantiation of delegates is to pre-instantiate it:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
private static Comparison<string> OrdinalIgnoreCaseComparison = StringComparer.OrdinalIgnoreCase.Compare;
...

...
Sort(lines, OrdinalIgnoreCaseComparison);
...

Which will be translated by the compiler to:

static void Sort(string[] lines, Comparison<string> comparison)
{
    Array.Sort(lines, comparison);
}

...
private static Comparison<string> OrdinalIgnoreCaseComparison = new Comparison<string>(StringComparer.OrdinalIgnoreCase.Compare);
...

...
Sort(lines, OrdinalIgnoreCaseComparison);
...

Now, only one instance of the delegate will be created.

Anonymous functions

C# 2.0 also introduced the concept of anonymous method expressions as a way to write unnamed inline statement blocks that can be executed in a delegate invocation.

Like a method group, an anonymous function expression can be implicitly converted to a compatible delegate.

C# 3.0 introduced the possibility of declaring anonymous functions using lambda expressions.

Being a new language concept allowed the compiler designers to interpret the expressions in new ways.

The compiler can generate a static method and optimize the delegate creation if the expression has no external dependencies:

This code:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

...
var r = ExecuteOperation(2, 3, (a, b) => a + b);
...

Will be translated to:

[Serializable]
[CompilerGenerated]
private sealed class <>c
{
      public static readonly <>c <>9 = new <>c();

      public static Func<int, int, int> <>9__4_0;

      internal int <M>b__4_0(int a, int b)
      {
            return a + b;
      }
}
...

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

...
var r = ExecuteOperation(2, 3, <>c.<>9__4_0 ?? (<>c.<>9__4_0 = new Func<int, int, int>(<>c.<>9.<M>b__4_0)));
...

The compiler is, now, “smart” enough to instantiate the delegate only on the first use.

As you can see, the member names generated by the C# compiler are not valid C# identifiers. They are valid IL identifiers, though. The reason the compiler generates names like this is to avoid name collisions with user code. There is no way to write C# source code that will have identifiers with < or >.

This optimization is only possible because the operation is a static function. If, instead, the code was like this:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b));
...

We’d be back to a delegate instantiation for every invocation:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;

int <M>b__4_0(int a, int b) => Add(a, b);

...
var r = ExecuteOperation (2, 3, new Func<int, int, int> (<M>b__4_0));
...

This is due to the operation being dependent on the instance invoking the operation.

On the other hand, if the operation is a static function:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b));
...

The compiler is clever enough to optimize the code:

[Serializable]
[CompilerGenerated]
private sealed class <>c
{
      public static readonly <>c <>9 = new <>c();

      public static Func<int, int, int> <>9__4_0;

      internal int <M>b__4_0(int a, int b)
      {
            return Add(a, b);
      }
}
...

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var r = ExecuteOperation(2, 3, <>c.<>9__4_0 ?? (<>c.<>9__4_0 = new Func<int, int, int>(<>c.<>9.<M>b__4_0)));
...

Closures

Whenever a lambda (or anonymous) expression references a value outside of the expression, a closure class will always be created to hold that value, even if the expression would, otherwise, be static.

This code:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
var o = GetOffset();
var r = ExecuteOperation(2, 3, (a, b) => Add(a, b) + o);
...

Wiil cause the compiler to generate this code:

[CompilerGenerated]
private sealed class <>c__DisplayClass4_0
{
      public int o;

      internal int <N>b__0(int a, int b)
      {
            return Add(a, b) + o;
      }
}

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

static int Add(int a, int b) => a + b;

...
<>c__DisplayClass4_0 <>c__DisplayClass4_ = new <>c__DisplayClass4_0();
<>c__DisplayClass4_.o = GetOffset();
ExecuteOperation(2, 3, new Func<int, int, int>(<>c__DisplayClass4_.<M>b__0));
...

Now, not only a new delegate will be instantiated, an instance of class to hold the dependent value. This compiler generated field to capture the variables is what is called in computer science a closure.

Closures allow the generated function to access the variables in the scope where they were defined.

However, by capturing the local environment or context, closure can unexpectedly hold a reference to resources that would otherwise be collected sooner causing them to be promoted to highier generations and, thus, incur in more CPU load because of the work the garbage collector (GC) needs to perform to reclaim that memory.

Static anonymous functions

Because it’s very easy to write a lambda expression that starts with the intention of being static and ends up being not static, C# 9.0 introduces static anonymous functions by allowing the static modifier to be applied to a lambda (or anonymous) expression to ensure that the expression is static:

var r = ExecuteOperation(2, 3, static (a, b) => Add(a, b));

If the same changes above are made, now the compiler will “complain”:

var o = GetOffset();
var r = ExecuteOperation(2, 3, static (a, b) => Add(a, b) + o); // Error CS8820: A static anonymous function cannot contain a reference to 'o'

Workarounds

What can a developer do to avoid these unwanted instantiations?

We’ve seen what the compiler does, so, we can do the same.

With this small change to the code:

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;
Func<int, int, int> addDelegate;

...
var r = ExecuteOperation(2, 3, addDelegate ?? (addDelegate = (a, b) => Add(a, b));
...

The only thing the compiler will have to do now is add the delegate instantiation, but the same instance of the delegate will be used throughout the lifetime of the enclosing type.

int ExecuteOperation(int a, int b, Func<int, int, int> operation)
{
    return operation(a, b);
}

int Add(int a, int b) => a + b;
Func<int, int, int> addDelegate;

...
var r = ExecuteOperation(2, 3, addDelegate ?? (addDelegate = new Func<int, int, int>((a, b) => Add(a, b)));
...

Closing

We’ve seen the different ways of using delegates and the code generated by the compiler and its side effects.

Delegates have powerful features such as capturing local variables. And while these features can make you more productive, they aren’t free. Being aware of the differences in the generated code allows making informed decisions on what you value more for a given part of your application.

Instantiating a delegate more frequently can incur performance penalties by allocating more memory which also increases CPU load because of the work the garbage collector (GC) needs to perform to reclaim that memory.

For that reason, we’ve seen how we can control the code generated by the compiler in a way the best suits our performance needs.

31 comments

Comments are closed. Login to edit/delete your existing comments

  • David Johnston

    Nice post!

    I think the

    static void Sort(string[] lines)

    method in the Implicit Conversion section is missing the Comparison<string> comparison parameter in the three examples shown.

  • Marius Ungureanu

    My general preference in these kind of scenarios is to add a state parameter to the Func signature and the ExecuteOperation method. Then you could have an overload like like:

    public int ExecuteOperation<T>(int a, int b, T state, Func<int, int, T, int> operation) =>
        operation(a, b, state);

    It adds flexibility to the caller to add a state parameter if needed, allowing for static lambdas:

    ExecuteOperation(2, 3, (a, b, state) => a + b + state)

    This avoids any hidden details at callsite.

    • Paulo MorgadoMicrosoft employee

      Mine too. I hope the next round of .NET performance optimizations introduces several overloads to that effect. That was common practice for APM.

      • Martin Enzelsberger

        I just wanted to write a comment about this pattern: I feel it is a huge missed opportunity to not showcase it in the article.
        While you pointed out the issue with implicit Closure creation, you failed to show a workaround for it.

        Your workaround only deals with a captured this reference, which doesn’t help people who need to incorporate a variable outside the lambda. It might even lead them to believe that by creating a new delegate each time and storing it in a variable they somehow improve performance, or even worse they store a delegate that captures a specific o value:

        var o = GetOffset();
        var r = ExecuteOperation(2, 3, addDelegate ?? (addDelegate = (a, b) => Add(a, b) + o);

        My suggestion would be to showcase a second workaround, as Marius outlines, for passing arbitrary state to a delegate without causing a closure allocation.

        • Paulo MorgadoMicrosoft employee

          This article is about delegate costs when using delegates. Although not stated (I admite it). it focus on the invocation of existing APIs.

          Marius’ comment is about how those APIs should be written in most cases. And I agree.

          I don’t think I understand your “huge missed opportunity” comment, though. Either you let the compiler build the closure over the local value or you do it, but you’ll need one.

  • Brian Ding

    Is this a typo?
    There is no was to write C# source code that will have identifiers with < or >.
    was –> way

    • Paulo MorgadoMicrosoft employee

      No it's not a typo. It's valid IL and that's why the C# compiler generates identifiers like that. Since there's no way for anyone to write C# code with such identifiers, there's no possibility for name collisions.

      • Bryan White

        Please check/re-read Brian’s comment, especially his last line.
        I think that you are seeing what you think is there, rather than what is actually there.
        I do not believe that your phrase “There is no was to write C# source code” is correct.
        I believe that the word ‘was’ should actually be ‘way’ – as Brian says.

  • Mister Magoo

    Really interesting read, thanks.

    By the way – the twitter link in your profile is wrong – it has “twiter” dot com, with one “t” – which is probably not where you want to send people 🙂

  • Richard Deeming

    “Initially (versions 1.0 and 1.2), …”

    Shouldn’t that be “versions 1.0 and 1.1“?

    • Jared ParsonsMicrosoft employee

      This is still a change we are considering for a future version of C# but it has the following considerations:

      1. It’s a moderately complex change
      2. It requires changing a part of our code that is historically prone to unintended regressions
      3. It will definitely break a set of customers who are depending on method group conversions having distinct identities (intentionally or unintentionally)

      None of these are hard blockers to us doing the change. In fact we’ve generally accepted this are just costs of doing the work here.

      At the same time we don’t have strong evidence that these allocations significantly contribute to the performance of .NET programs overall. Yes there are spot cases we find for this but it’s not a recurring problem that we see. Hence at the moment the benefit doesn’t seemingly outweigh the negatives. If we found that evidence though we’d likely move forward with this change.

  • Dominic Catherin

    Great digestible post. Love the acknowledgement and work MS is doing around allocations and making the community aware of this. It certainly way to easy to allocate especially when using Linq and lambdas. Allocations add up so its great to see posts like this.

  • Thomas Levesque

    Nice post, thanks!
    The article covers the cost of instantiating delegates, but what about the cost of invoking them (vs. directly invoking the method)?

    • Paulo MorgadoMicrosoft employee

      That’s a whole other topic, Thomas.

      Sometimes there’s just no choice, like with LINQ.

      But that might be a good segway for a part 2.