Revisions to previous discussion of the implementation of anonymous methods in C#


Welcome to CLR Week.

Yes, it's been a long time since the last CLR Week. Some people might consider that a feature.

Anyway, I'm going to start by calling attention to some revisions to previous discussion of the implementation of anonymous methods in C#.

The first revision is one most people are well aware of, namely that the scope of the control variable of a foreach statement is now the controlled statement. What this means for you is that closing over the loop control variable of a foreach statement is not dangerous. Note, however, that closing over the loop control variable of a for statement is still dangerous.

The second revision is that noncapturing lambdas are no longer wrappers around a static method. Even if the lambda captures nothing, it is still converted to an instance method (of an anonymous type).

The reason given by Kevin Pilch-Bisson is that "delegate invokes are optimized for instance methods and have space on the stack for them. To call a static method they have to shift parameters around."

Let's unpack that explanation.

Recall that instance methods have a hidden this parameter, whereas static methods do not. Suppose you want to forward a call from one method to another. For concreteness, let's say you have

class C1
{
 public void M1(int x, int y, int z)
 {
  System.Console.WriteLine("From {0} to {1} via {2}", x, y, z);
 }
 static public void S1(int x, int y, int z)
 {
  System.Console.WriteLine("From {0} to {1} via {2}", x, y, z);
 }
}

class C2
{
 private C1 c1 = new C1();
 static private C1 s1 = new C1();

 public void M2(int x, int y, int z)
 {
  c1.M1(x, y, z);
 }
 public void S2(int x, int y, int z)
 {
  C1.S1(x, y, z);
 }
 public void M2S(int x, int y, int z)
 {
  C1.S1(x, y, z);
 }
 public void S2M(int x, int y, int z)
 {
  s1.M1(x, y, z);
 }
}

Since the layouts for the parameters to both C1.M1() and C2.M2() method match, C2.M2() can get away with the following:

  • Fetch this.c1.
  • Validate that the fetched value is not null.
  • Replace this with the fetched value.
  • Jump to C1.M1.

The assembly for C2.M2 on x86 would go something like this:

; fastcall convention passes
; the first parameter (this) in ecx
; the second parameter (x) in edx
; remaining parameters (y, z) on the stack

C2.M2:
    mov  ecx, [ecx].c1  ; fetch this.c1
    cmp  ecx, [ecx]     ; null check
    jmp  C1.M1          ; all the other parameters are already set

Similarly, forwarding a call from one static method to another can reuse the stack frame as-is:

C2.M2:
    jmp C1.M1           ; all parameters are already set properly

However, forwarding from an instance method to a static method or vice versa isn't so easy. The compiler would either have to generate a traditional non-tail call:

C2.M2S:
    mov  ecx, edx       ; put x into ecx
    mov  edx, [esp][4]  ; put y into edx
    push edx, [esp][8]  ; push z
    call C1.S1
    ret  8

C2.S2M:
    push [esp][4]       ; push z
    push edx            ; push y
    mov  edx, ecx       ; put x into edx
    mov  ecx, [C2.s1]   ; put C2.s1 into ecx
    cmp  ecx, [ecx]     ; null check
    call C1.M1          ; call it
    ret  8

Or maybe the compiler plays funny stack rewriting games:¹

C2.M2S:
    mov  ecx, edx       ; put x into ecx
    pop  eax            ; pop return address
    pop  edx            ; pop y into edx
                        ; leave z on the stack
    push eax            ; restore return address
    jmp  C1.S1

C2.S2M:
    pop  eax            ; pop return address
    push edx            ; push y
    push eax            ; restore return address
    mov  edx, ecx       ; put x into edx
    mov  ecx, [C2.s1]   ; put C2.s1 into ecx
    cmp ecx, [ecx]      ; null check
    jmp C1.M1

Both of these are worse than the case where the call is forwarded to a function of matching ilk.

Since delegate invoke is done instance-style, the code to dispatch the delegate to the lambda is more efficient if the lambda is also instance.

Since the language specification does not specify the nature of the lambda, whether the delegate represents a static or instance method is an implementation detail that can change at any time.

And it did.

¹ Note that these stack rewriting games are not available to x64 because of alignment requirements. On x64, we are forced to generate a traditional non-tail call.

Comments (9)

  1. Bradley says:

    Personally, I love CLR week.

  2. Joshua says:

    I am no longer embarrassed at passing List.Add() to a function expecting a delegate.

  3. Nico says:

    I, for one, welcome our temporary CLR overlord.

    That's interesting about the for loop variable closure. I had kind of assumed (oops) that when they changed the behavior of foreach it also applied to for, so thanks for calling it out.

    For anyone who wants to see this in action: https://dotnetfiddle.net/GtAZ32

  4. Ivan K says:

    I remember that ReSharper would emit a warning / suggestion for any method that could be made static (whether called from another static or not). Googling, I see that FxCop did too. I found the warning helpful for design, but also thought based on the documentation that making the method static improved performance a teeny tiny bit (eliminate the null this check). I guess that turns out to not be the case anymore, maybe with x64 more common? I'm not really an x86 assembly person so don't know the clock counts, etc of the instructions on new cpu's.
    https://www.jetbrains.com/help/resharper/MemberCanBeMadeStatic.Local.html

    1. Zarat says:

      Note that the articles didn't make any statement over the performance of static methods in general, only for the case where you take a *delegate* to a static methods. If you call the static method yourself the compiler can actually see the method call and optimize for it (instead of it being hidden in a delegate)

    2. Zarat says:

      Also, Raymonds article is about forwarding calls. He explains that these are always faster when the signature matches exactly (because then no extra work needs to be done). Again this doesn't make a general statement over the performance static methods.

  5. 640k says:

    All these alternatives are at least better than what powershell has implemented (in .net clr!), both as default but also even considering that it's possible to close (an re-close) the function explicitly in powershell.

  6. Martin Bonner says:

    I followed the "null check" link. I think the behaviour of the typical commenter has got better over the last decade (or maybe I'm not reading enough comments).

Skip to main content