Skip Navigation

C# Syntax Lowering


I was catching up on my ever-growing backlog of YouTube videos when I came across Nick Cosentino's video "UNEXPECTED 87% Performance Boost! - C# Collection Initializers". The video piqued my interest and I came away knowing a little more about C#.

I also went straight into Visual Studio to replicate his benchmarks and found a few interesting things due to lowering. The original benchmarks focused on List<T> so I started playing with different return types.

[MemoryDiagnoser]
public class CollectionInitializerBenchmarks
{
    [Benchmark(Baseline = true)]
    public IReadOnlyCollection<string> Classic_NoCapacity()
        => new List<string>() { "Apple", "Banana", "Orange" };

    [Benchmark]
    public IReadOnlyCollection<string> Classic_SetCapacity()
        => new List<string>(capacity: 3) { "Apple", "Banana", "Orange" };

    [Benchmark]
    public IReadOnlyCollection<string> CollectionExpression_ReadOnlyCollection()
        => ["Apple", "Banana", "Orange"];

    [Benchmark]
    public List<string> CollectionExpression_List()
        => ["Apple", "Banana", "Orange"];

    [Benchmark]
    public IReadOnlyCollection<string> ManuallyAdd_NoCapacitySet()
    {
        List<string> list = [];
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }

    [Benchmark]
    public IReadOnlyCollection<string> ManuallyAdd_CapacitySet()
    {
        List<string> list = new(3);
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }
}

After a few runs, I was curious to know what was going on behind the scenes with the collection initialization syntax List<int> numbers = [];. Enter SharpLab.

A large portion of changes to C# in recent years have been syntactical sugar that gets lowered. In a nutshell, lowering is rewriting a high-level syntax or language feature into lower-level equivalents. In C#, auto-properties are a great example as the auto-property syntax gets lowered into the full property with a backing field.

// auto-property
public string Test { get; set; }
// lowered backing field and full property
private string <Test>k__BackingField;
public string Test
{
    [CompilerGenerated]
    get { return <Test>k__BackingField; }
    [CompilerGenerated]
    set { <Test>k__BackingField = value; }
}

SharpLab is my tool of choice for seeing lowered code and things got interesting when I started looking thru the lowered code for the different return types (I've sorted the methods to make comparison easier).

public class CollectionInitializerBenchmarks
{
    public IReadOnlyCollection<string> Classic_NoCapacity()
    {
        List<string> list = new List<string>();
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }

    public IReadOnlyCollection<string> ManuallyAdd_NoCapacitySet()
    {
        List<string> list = new List<string>();
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }

    public IReadOnlyCollection<string> Classic_SetCapacity()
    {
        List<string> list = new List<string>(3);
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }

    public IReadOnlyCollection<string> ManuallyAdd_CapacitySet()
    {
        List<string> list = new List<string>(3);
        list.Add("Apple");
        list.Add("Banana");
        list.Add("Orange");
        return list;
    }

    public IReadOnlyCollection<string> CollectionExpression_ReadOnlyCollection()
    {
        string[] array = new string[3];
        array[0] = "Apple";
        array[1] = "Banana";
        array[2] = "Orange";
        return new <>z__ReadOnlyArray<string>(array);
    }

    public List<string> CollectionExpression_List()
    {
        List<string> list = new List<string>();
        CollectionsMarshal.SetCount(list, 3);
        Span<string> span = CollectionsMarshal.AsSpan(list);
        int num = 0;
        span[num] = "Apple";
        num++;
        span[num] = "Banana";
        num++;
        span[num] = "Orange";
        num++;
        return list;
    }
}

The Classic and ManuallyAdd methods were pretty much as expected but the CollectionExpression were VERY different. Not only can the collection initializer set the capacity to the given number of items but it lowers differently based on the return type. If C# updates further optimize the lowered code, we'll be able to take advantage of those optimizations with no code changes!

For reference, here are the benchmark from one of my final runs:

Method Mean Ratio Allocated Alloc Ratio
Classic_NoCapacity 13.204 ns 1.00 88 B 1.00
Classic_SetCapacity 7.896 ns 0.60 80 B 0.91
CollectionExpression_ReadOnlyCollection 5.812 ns 0.44 72 B 0.82
CollectionExpression_List 9.031 ns 0.68 80 B 0.91
ManuallyAdd_NoCapacitySet 12.276 ns 0.93 88 B 1.00
ManuallyAdd_CapacitySet 8.265 ns 0.63 80 B 0.91

Relavent Links: