Improving performance in .NET

Small changes can make your code 10x faster

Improving performance in .NET

The Requirement

A simple requirement was brought up in a project I was working on, format a CPF (Brazilian National ID) adding some standard separators (“.” and “-”), pretty straightforward as:

Input: “12345678909”
Expected output: “123.456.789–09”

The Original Solution

Well then, there are multiple ways to achieve that, but my first approach was something very simple:

public string Format(string cpf)
{
    // Validation for null/length skipped for brevity      
    return cpf.Substring(0, 3) + "." +         
           cpf.Substring(3, 3) + "." + 
           cpf.Substring(6, 3) + "-" + 
           cpf.Substring(9, 2);  
}

Basically, I just used Substring to split the CPF into four parts, inserted the corresponding separators and that’s it. This implementation was working fine but I wanted to double check other possibilities looking for faster options.

The Stack Overflow Solution

As every developer I searched in Google for a solution and, of course, I ended up in Stack Overflow. In the best voted answer, I found an implementation quite different:

public string Format(string cpf)
{
    return Convert.ToUInt64(cpf).ToString(@"000\.000\.000\-00");
}

This version also works perfectly and at first look it seems "ok", but when we take a closer look, we could notice something important:

  • To allow use of the number format string - that allows defining a mask for a number in the ToString method - the string is converted to UInt64.

Pretty clever, huh? Not much.

I had doubts if this was really a better implementation, so I decided to use Benchmark.DotNet to compare the approaches. Here the results:

VersionMediumRatio
Baseline solution (Format with Substring)115.27 ns0.46
Stack Overflow solution (Format with Convert)252.66 ns1.00

The baseline version using Substring takes less than half of the time of the Google one using Convert.ToInt64.

So, surprisingly, the StakeOverflow solution is way slower than the original one.

New Solution

Even that the original solution is an acceptable implementation there are some problems over there too, the main one is that using Substring for extracting the CPF sections generates new allocations in the memory.

In order to apply improvements, we need to go back to the original problem and analyze it better. The requirement can be translated to: “given a string return a new array of chars, adding additional characters in between”.

This makes it clearer that we can use the input instead of transforming it, avoiding unnecessary memory allocation and any type of conversion.

To achieve that there are structures in C# like Span<T> that were created precisely for situations like this.

So, with this in mind I ended up with this new solution:

public ReadOnlySpan<char> FormatarCpf(string cpf)
{
    Span<char> formattedValue = stackalloc char[14];

    Copy(cpf, startIndex: 0, length: 3, ref formattedValue, insertIndex: 0);
    formattedValue[3] = '.';

    Copy(cpf, startIndex: 3, length: 3, ref formattedValue, insertIndex: 4);
    formattedValue[7] = '.';

    Copy(cpf, startIndex: 6, length: 3, ref formattedValue, insertIndex: 8);
    formattedValue[11] = '-';

    Copy(cpf, startIndex: 9, length: 2, ref formattedValue, insertIndex: 12);

    return formattedValue.ToArray();

    static void Copy(string origin, int startIndex, int length, ref Span<char> destination, int insertIndex)
    {
        for (int i = startIndex, j = insertIndex; i < (startIndex + length); i++, j++)
            destination[j] = origin[i];
    }
}

Main changes:

  • To store the formatted value I'm using a char array Span<char> allocated in stack memory and defining their size: stackalloc char[14]  — since we already know the full size.

  • The original string values are copied one by one into the respective positions of the formatted array. I used a “Local Function” only to avoid repetition and optimize the processing.

  • The return value was modified to ReadOnlySpan — nothing more than a read-only char array.

Final result:

image.png

25 nanoseconds! 10x faster than the version with conversion and 4x faster than the original version with substring.

Conclusion: it is worth spending time understanding better how the language works and optimizing your code to deliver quality software.

The complete code and tests with metrics are available on GitHub.


References: