LanguagePart 2

The Delegate Is Optional. The Display Class Is Not

A lambda that captures an int allocated 24 bytes on .NET 10.0.12; the same lambda reading a constant allocated none. The delegate was optional, the display class was not.

A worn steel slide fixture on a workbench; a single steel block sits clamped in its own carrier beside a brass jaw and a star-shaped hand wheel, with a row of loose identical blocks standing behind and one violet-capped bolt on the latch.

In short

  1. A lambda that captured one int allocated 24 bytes per call on .NET 10.0.12 whether it went to Enumerable.Count or was invoked locally, and 88 bytes when it was passed to a method the JIT does not inline; the same lambda reading a constant allocated 0 bytes in all three positions.
  2. Roslyn moves the captured local into a field of a sealed, compiler-generated display class that is created on every call, while a non-capturing lambda is cached once on a singleton and allocates only on its first call.
  3. The 24 bytes are the display class and the extra 64 bytes of the 88 are the Func<int, bool> delegate, which the JIT removed when the consumer was inlined into the creating method and the call could be bound to the lambda's own method.
  4. The delegate's removal is a property of one compiled copy rather than of the source, and the display class survived in every capturing shape measured.

Capturing one local allocated 24 bytes when the lambda went to Count and 88 when it went to a helper. The same lambda reading a constant allocated nothing in either position.

The two lambdas differ by a single token: a local instead of a literal. The measured difference is one object in the first case and two in the second, and the line of C# that allocates is not in the source at all. The cause is a class the compiler writes and a delegate the JIT may or may not remove, and the second half of that is the part the usual advice gets wrong.

The reasoning is the one in The Box You Pay For Is the One That Escapes: an allocation the JIT can see from creation to use can vanish, and one that crosses a call boundary cannot. A closure follows the same rule with an exception, because one of its two objects never vanished.

A captured local stops being a local

The two lambdas, as written, differ in one operand:

C#
public static int LinqNonCapturing(int[] data) => data.Count(x => x > 512);

public static int LinqCapturing(int[] data)
{
    var threshold = data.Length * 8;
    return data.Count(x => x > threshold);
}

For a 64-element input threshold is 512, so both return the same count. Decompiled with AnonymousMethods = false, so that the lowering stays visible, the capturing method reads:

C#
public static int LinqCapturing(int[] data)
{
    <>c__DisplayClass2_0 <>c__DisplayClass2_ = new <>c__DisplayClass2_0();
    <>c__DisplayClass2_.threshold = data.Length * 8;
    return data.Count(<>c__DisplayClass2_.<LinqCapturing>b__0);
}

<>c__DisplayClass2_0 is a sealed class marked [CompilerGenerated] with one field, public int threshold, and the lambda body as an instance method. The local no longer exists; it is a field on an object created at IL_0000 by newobj. The Func<int, bool> follows with ldftn at IL_0013 and a second newobj at IL_0019. No cache check precedes either.

The non-capturing method takes the other path. Its body moves onto a singleton <>c, and the delegate is kept in a static field:

C#
return data.Count(<>c.<>9__1_0 ?? (<>c.<>9__1_0 = <>c.<>9.<LinqNonCapturing>b__1_0));

In IL the field is loaded and brtrue.s skips the newobj when it is set, so the allocation runs on the first call only.

0, 24 and 88 bytes from one predicate

Each lambda was measured in three positions. Enumerable.Count over the array, a local Func<int, bool> invoked in a foreach inside the same method, and CountWhere, an identical loop marked [MethodImpl(MethodImplOptions.NoInlining)]. The input was 64 int values from Random(11) in the range 0 to 1023, and every shape returned 32. BenchmarkDotNet’s Allocated column carries the result:

Text
| Method             | Mean     | Error    | StdDev   | Ratio | RatioSD | Gen0   | Code Size | Allocated | Alloc Ratio |
|------------------- |---------:|---------:|---------:|------:|--------:|-------:|----------:|----------:|------------:|
| NoLambda           | 17.35 ns | 0.161 ns | 0.135 ns |  1.00 |    0.01 |      - |        NA |         - |          NA |
| LinqNonCapturing   | 17.29 ns | 0.367 ns | 0.571 ns |  1.00 |    0.03 |      - |     792 B |         - |          NA |
| LinqCapturing      | 18.70 ns | 0.147 ns | 0.131 ns |  1.08 |    0.01 | 0.0057 |     644 B |      24 B |          NA |
| LocalNonCapturing  | 16.15 ns | 0.065 ns | 0.054 ns |  0.93 |    0.01 |      - |        NA |         - |          NA |
| LocalCapturing     | 18.57 ns | 0.042 ns | 0.037 ns |  1.07 |    0.01 | 0.0057 |        NA |      24 B |          NA |
| OpaqueNonCapturing | 16.85 ns | 0.243 ns | 0.216 ns |  0.97 |    0.01 |      - |        NA |         - |          NA |
| OpaqueCapturing    | 26.97 ns | 0.192 ns | 0.180 ns |  1.55 |    0.02 | 0.0210 |        NA |      88 B |          NA |

Every non-capturing shape allocated nothing. Both capturing shapes with an inlinable consumer allocated 24 bytes, which is a 16-byte object header and method table plus a 4-byte int, rounded up to 8. OpaqueCapturing allocated 88, which is the same display class plus a 64-byte Func<int, bool> on this runtime. The NoLambda row is the control: the loop written by hand, with nothing to allocate.

Time is secondary. The capturing shapes with an inlinable consumer ran 1.07 to 1.08 times the control in this run and 1.07 to 1.16 across the two runs; OpaqueCapturing ran 1.55 times, and 1.55 to 1.69 across both. The non-capturing and NoLambda means spread over 1.20 ns here and 0.72 ns in the reproduction, so no ordering among them is claimed.

A GC.GetAllocatedBytesForCurrentThread() loop of 1,000,000 calls per shape gave 0.00 for every non-capturing shape, 24.00 for LocalCapturing and 88.00 for OpaqueCapturing. It gave 87.94 for LinqCapturing, which BenchmarkDotNet had measured at 24.

The delegate goes only when it stays in the method and the JIT can bind the call

The difference between 24 and 88 is explained by the JIT listing, captured with DOTNET_JitDisasm in the benchmark processes. Counting CORINFO_HELP_NEWSFAST calls, the allocation helper, on each shape’s per-call path gave 0 for every non-capturing shape, 1 for LinqCapturing, 1 for LocalCapturing and 2 for OpaqueCapturing. The non-capturing shapes contain one such call each, behind the empty-cache branch.

In LinqCapturing, Count over an int[] is inlined into the creating method together with the lambda body. The body reads threshold straight from the display class, and no delegate object exists. The JIT saw creation, consumer and call in one method, bound the call to the lambda’s own method and removed the Func. In OpaqueCapturing both objects are allocated before a tail call to CountWhere. The delegate crosses a NoInlining boundary, so it has to exist; inside CountWhere the JIT still guards on the delegate’s method pointer and inlines the lambda body behind the check, but that does not take back the allocation.

The 87.94 is the same rule failing in a different compiled copy. In the cross-check’s on-stack-replacement copy of the measuring loop, seen in a diagnostic run of the cross-check with DOTNET_JitDisasm on an earlier build without the opaque shapes, guarded devirtualisation predicted the non-capturing lambda as the target. The guard failed on every element, the call stayed indirect, and the Func had to be allocated: 88 bytes. The shortfall from 88 comes from the first 1,000 iterations of each measured loop, which run in Tier0 and call the standalone Tier1 method that allocates 24. Why the guard predicted the wrong lambda was not tested; the profile Count shares with the non-capturing variant is the likeliest reading.

So the delegate’s removal is an outcome of one compiled copy, not a guarantee of the language. The display class has no such dependence. It stayed on the heap at 24 bytes in every capturing shape, and the capturing lambda never reached 0.

What this changes

  1. Read the lambda body for any local of the enclosing method. In these measurements a constant gave 0 bytes and a captured int cost at least the 24-byte display class whatever the consumer did, and 88 bytes when the delegate survived.
  2. Do not read the delegate’s allocation off the source. The same lambda measured 24 bytes when the consumer was inlined and 88 when it was not, and one cross-check copy of the same code measured 87.94.
  3. Confirm in a decompiler instead of guessing. A nested type named <>c__DisplayClass… means a per-call object, and a static <>c.<>9__… field means a cached delegate. With a single captured int, one object per call in Allocated or in the NEWSFAST count is the display class with the delegate removed, and two is the delegate surviving as well.

Measured on .NET SDK 10.0.401 / runtime 10.0.12, Linux (Ubuntu 24.04.5 LTS) arm64 inside Docker with --network none --cap-drop ALL --security-opt no-new-privileges --user 501:20 --pids-limit 512 and no resource limits (nproc 12, cpu.max max 100000, memory.max max). Timing and the Allocated column come from BenchmarkDotNet 0.15.8 with [MemoryDiagnoser] on a job that is the default job plus the JIT environment variables; the byte counts were cross-checked with GC.GetAllocatedBytesForCurrentThread() over 1,000,000 calls per shape after five warm-up rounds, which sees only the calling thread. The lowered view comes from ICSharpCode.Decompiler 9.1.0.7988. BenchmarkDotNet’s disassembly diagnoser exported assembly for two of seven shapes on Arm64, so the per-shape allocation sites come from DOTNET_JitDisasm. What this harness cannot see: the Docker Desktop VM’s own CPU and memory allocation, the host CPU model (BenchmarkDotNet reports that line as -), x64 code, other runtime versions, inputs other than 64 int values, a captured type other than int, and a delegate stored in a field, returned, or passed to a virtual or interface consumer.

Frequently asked

Questions this raises

Does a lambda that captures a local always allocate two objects?

No. On .NET 10.0.12 it always allocated the display class, 24 bytes for one captured int, but the delegate was allocated only when it escaped the creating method or the JIT could not bind the call to the lambda's own method. Passed to Enumerable.Count on an int array it measured 24 bytes; passed to a non-inlined method it measured 88.

How can a lambda that captures a variable be told apart from one that does not?

A lambda that reads a local of the enclosing method gets a display class, which shows up in a decompiled build as a nested type named like c__DisplayClass2_0 with the local as a public field. A lambda that reads nothing from the enclosing method is cached in a static field on the nested type named c, and allocated 0 bytes after its first call.

Can the JIT remove the allocation of a capturing lambda entirely?

Not in the shapes measured here. The JIT removed the delegate when the consumer was inlined into the creating method, but the display class stayed on the heap at 24 bytes, so a capturing lambda never reached 0 bytes.

Arrow keys to move, Enter to open.