Does readonly make your code faster?

4/23/2024
7 minute read

In this blog post we will discover whether or not the readonly modifier can make your code faster. So without further ado let's get started.

Update 24th of April: I originally used the DEBUG build for IL/JIT code, which is obviously never a good idea when you talk about performance.I did correct the blog post. Thanks @lassevk for noticing!

What is the readonly modifier?

The readonly modifier is a keyword in C# that can be applied to fields. When a field is marked as readonly it means that the field can only be assigned a value during the declaration or in the constructor of the class. Once the field has been assigned a value it cannot be changed.

An example:

public class Class 
{
    private readonly int readonlyField = 100;
    private int nonReadonlyField = 100;
    
    public int RO() {
        return readonlyField + readonlyField;
    }
    
    public int NonRO() {
        return nonReadonlyField + nonReadonlyField;
    }
}

Does readonly make your code faster?

To answer that question we have to look at the IL code that is generated by the C# compiler. Let's take a look at the IL code for the RO method and the NonRO method.

.class public auto ansi beforefieldinit Class
    extends [System.Runtime]System.Object
{
    // Fields
    .field private initonly int32 readonlyField
    .field private int32 nonReadonlyField

    // Methods
    .method public hidebysig 
        instance int32 RO () cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 14 (0xe)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld int32 Class::readonlyField
        IL_0006: ldarg.0
        IL_0007: ldfld int32 Class::readonlyField
        IL_000c: add
        IL_000d: ret
    } // end of method Class::RO

    .method public hidebysig 
        instance int32 NonRO () cil managed 
    {
        // Method begins at RVA 0x205f
        // Code size 14 (0xe)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld int32 Class::nonReadonlyField
        IL_0006: ldarg.0
        IL_0007: ldfld int32 Class::nonReadonlyField
        IL_000c: add
        IL_000d: ret
    } // end of method Class::NonRO

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x206e
        // Code size 23 (0x17)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.s 100
        IL_0003: stfld int32 Class::readonlyField
        IL_0008: ldarg.0
        IL_0009: ldc.i4.s 100
        IL_000b: stfld int32 Class::nonReadonlyField
        IL_0010: ldarg.0
        IL_0011: call instance void [System.Runtime]System.Object::.ctor()
        IL_0016: ret
    } // end of method Class::.ctor

} // end of class Class

The methods are identical, well except for the field they are accessing. The only difference is int the declaration of the fields. The readonlyField is declared as initonly and the nonReadonlyField is not. The initonly keyword is used to mark a field as readonly (basically readonly in IL terms).

Now, there could be potentially some performance benefits to using readonly fields. The JIT compiler could potentially optimize the code better if it knows that a field is readonly. But in this case, the JIT compiler does not optimize the code differently. The code generated for the RO method and the NonRO method is identical.

Here some JIT code:

Class.RO()
    L0000: mov eax, [ecx+4]
    L0003: add eax, eax
    L0005: ret

Class.NonRO()
    L0000: mov eax, [ecx+8]
    L0003: add eax, eax
    L0005: ret

It is again completely identical. The JIT compiler does not optimize the code differently if a field is marked as readonly (in this case). I also tried this with more complex objects/classes instead of value types, but the result was the same. I couldn't find a way to make the JIT compiler optimize the code differently if a field is marked as readonly.

If I run a benchmark with something like the above, you get what you would expect in this case - the somewhat same runtime:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<ManyFieldsBenchmark>();

public class ManyFieldsBenchmark
{
    private class ReadOnlyFields
    {
        public readonly int Field1 = 1;
        public readonly int Field2 = 2;
        public readonly int Field3 = 100;
        public readonly int Field4 = 300;
        public readonly int Field5 = 300;
        public readonly int Field6 = 300;
    }

    private class NonReadOnlyFields
    {
        public int Field1 = 1;
        public int Field2 = 2;
        public int Field3 = 100;
        public int Field4 = 300;
        public int Field5 = 300;
        public int Field6 = 300;
    }

    private readonly ReadOnlyFields readOnlyInstance = new();
    private readonly NonReadOnlyFields nonReadOnlyInstance = new();

    [Benchmark]
    public int AccessReadOnlyFields()
    {
        var sum = 0;
        for (var i = 0; i < 10000; i++)
        {
            sum += readOnlyInstance.Field1
                   + readOnlyInstance.Field2
                   + readOnlyInstance.Field3
                   + readOnlyInstance.Field4
                   + readOnlyInstance.Field5
                   + readOnlyInstance.Field6;
        }
        
        return sum;
    }

    [Benchmark]
    public int AccessNonReadOnlyFields()
    {
        var sum = 0;
        for (var i = 0; i < 10000; i++)
        {
            sum += nonReadOnlyInstance.Field1 
                   + nonReadOnlyInstance.Field2
                   + nonReadOnlyInstance.Field3 
                   + nonReadOnlyInstance.Field4
                   + nonReadOnlyInstance.Field5
                   + nonReadOnlyInstance.Field6;
        }
        
        return sum;
    }
}
| Method                  | Mean     | Error    | StdDev   |
|------------------------ |---------:|---------:|---------:|
| AccessReadOnlyFields    | 17.58 us | 0.100 us | 0.094 us |
| AccessNonReadOnlyFields | 17.52 us | 0.052 us | 0.046 us |

We are in the margins of error. So if there is any performance benefit to using readonly fields, it is negligible in almost all cases. But then again, the main reason of using readonly fields is to make your code more robust and maintainable, not to make it faster. If you suspect that it can bring benefits then

  1. Measure it (create a baseline), implement the change and measure it again (against your baseline).
  2. Inform me, as I would be interested in seeing a case where it does make a difference.

struct vs readonly struct vs ref struct vs record struct

C# knows struct since its down of time. But there are also recent additions like readonly struct, record struct and ref struct.

This article will show what are the differences between those 4.

ReadOnlyCollection is not an immutable collection

In this blog post we discover how we can mutate a ReadOnlyCollection to have more or less entries than its original state. Readonly does not mean it is immutable. Also we will check out the ImmutableArray.

Passing by value or by reference - What is faster?

When we are passing objects around we can do this either via reference or by value. Which of those two methods is faster?

To answer this question we have to dive into a bit of info about what happens exactly when you pass something around and how the other side will receive this.

An error has occurred. This application may no longer respond until reloaded. Reload x