C# Performance tricks — Reducing heap allocations and execution time

Andre Santarosa
5 min readApr 27, 2022
Free photo Lane Competition Departure Line Arrival Arrival — Max Pixel

Whenever we talk about performance, the main concept is a hard-to-read code applying some weird strategies to extract some milliseconds from a small method. But this is not always the case. Sometimes small tricks can give uns a nice return with a very low cost regarding coding clearness.

Here I will suggest a few strategies to overcome those “performance villains” and make your code faster but still nice to read.

But before we jump into the topic, there is a golden rule to be followed.

Always measure and improve what needs improvement

There is no sense in improving things that are already performing well, first gather your metrics, find opportunities and then change things that need changes. Is also important to measure before, during, and after your implementations, sometimes unexpected effects can occur and it is crucial to have this covered.

Heap Allocations and GC Time

GC time is a cost we have to pay in exchange for a simpler way to write code, there is no getting rid of it, but we can surely reduce its impact on our programs’ execution.

GC only collects what is allocated at the heap, so the key here is to avoid allocation as much as we can, if you are not sure about how memory allocation works on .NET, I suggest you read a previous article about it.

Files

When working with large text files is always a better idea to read one lit at once if possible, this way, the chances of allocations at Large Object Heap areas are lower since the line would have to hold more than 85.000 chars (pure ASCII) to be allocated there. Small objects go to Gen0 at their first allocation and the chance of being removed quickly is high.

Let’s use an example of a trivial task: Read a CSV. This file used in this benchmark has around 5.7MB and 37k lines

Here we can see clearly how huge the difference is, the second method was around 25% quicker and the memory consumption was 34% lower.

But there are other interesting things to notice. Gen0 collection was a lot larger, but no allocation was made at Gen1 and Gen2. Why?

This is directly related to how ReadLines() works internally, it uses a StreamReader to read line after line on-demand, so the file is read partially unlike ReadAllText() which has the behavior of bringing to memory all the data at once.

And there is another thing to notice, being larger than 85.000 bytes, the file content would be allocated at LOH and has all the impacts that it brings.

Lists

Lists in C# are really a thing. They are really easy to work and we have at our hands LINQ that makes a lot of difference in productivity. But under the hood things can get fuzzy and we have to be sure about what we are doing.

One thing that we do all the time is to manipulate them to grow as our wish, just call and Add() and everything works, easy peasy.

But one thing we have to remember is that Lists at a lower level work with arrays and arrays are immutable. So theoretically, whenever we add a new item to a List, a new array would have to be instantiated with one more position available to fit the new length, then the content of the older array is copied and the new value is placed at the last position.

This is a very expensive task with high processing and memory impact, this moving thing should occur all the time when resizing, but C# has a smarter way to handle things.

Let’s instantiate a new list of stringsvar myStrings = new List<string>(); and check the Capacity property (it holds the number of positions of that list), the value will be 0 , ok, so far so good. But if we add one single item, the property value will be 4. Wait, what? Yep, the Capacityis now 4.

This happens because copying the array is expensive as said before, so the language tries to make things faster by pre-allocating some available space before it is even needed. And what would happen if we add another 3 elements to our list? In this case, the Capacity would still be 4 elements, but if we add the 5th element, the list would have now 8 positions, if we add the 9th now we would end up with a 16 position array. Whenever the limit value is reached and you try to add a new element, the list capacity doubles in a base2 progression, so 4–8–16–32–64–128–256…

Moving small arrays are not that expensive, but moving large arrays are, and we have a way to make it through this situation. new List<T> has an overload that accepts an integer, and this integer will be the List’s initial capacity. So instead of increasing the value as we add more elements, this list will have at its initial moment the capacity provided.

Let’s go to the benchmark

Once again we have a smaller execution time, Gen0 collect executions, and an lower memory allocation.

A question that maybe come to mind is: If I set an initial value and this value is not enough, would I end up with an exception? No, the inner array will be resized to the next number of the base2 progression, so in the case, you set your initial value to 300 and try to insert the 301st element, the inner will be resized and its new size will be 512.

Boxing/Unboxing

Boxing and Unboxing are also expensive tasks to be performed and should be avoided always as possible, but before showing how to avoid them, let’s understand what is boxing and unboxing.

In .NET all base types inherit directly or indirectly from object . This said we can pass any value to a method that receives an object typed parameter, but if the value you are passing is a Value Type, it will be transferred to the heap before the method receives this value. It will be put in a “box”, its content as so its type will be preserved, but this variable will be wrapped in an “object typed box”. Since the value is being transferred to the heap, a new allocation process is made and this portion of memory will have to be released later. This is the boxing.

The unboxing is the opposite. Is get a boxed value, let’s say an object myBoxedVale = 1; and do the following int myUnboxedValue = (int)1`. This process is also expensive and should be avoided.

How can we achieve flexibility and at the same time stay performative and keep the code clean? We can use Generics instead of working with object .

I believe that these strategies above are one of the most simple ways to achieve performance increase with a low cost regarding code maintainability and readability. Of course that there is still a lot to cover, we can point out for example Span<T> and Memory<T> that changed the game when dealing with arrays and is worth taking a look.

--

--

Andre Santarosa

C#/.Net Senior Developer @Viatel Ireland. Likes to cook on the weekends and to play old songs on the guitar.