Memory Optimization in C#: Effective Practices and Strategies
Everything you wanted to know about garbage collection, data structures, and RAM optimization
In the world of modern programming, efficient utilization of resources, including memory, is a key aspect of application development. Today we will talk about how you can optimize the resources available to you during development.
The C# programming language, although it provides automatic memory management through the Garbage Collection (GC) mechanism, requires special knowledge and skills from developers to optimize memory handling.
So, let's explore various memory optimization strategies and practices in C# that help in creating efficient and fast applications.
Before we begin - I would like to point out that this article is not a panacea and can only be considered as a support for your further research.
Working with managed and unmanaged memory
Before we dive into the details of memory optimization in C#, it's important to understand the distinction between managed and unmanaged memory.
Managed memory
This is memory whose management rests entirely on the shoulders of the CLR (Common Language Runtime). In C#, all objects are created in the managed heap and are automatically destroyed by the garbage collector when they are no longer needed.
Unmanaged memory
This is memory that is managed by the developer. In C#, you can handle unmanaged memory through interoperability with low-level APIs (Application Programming Interface) or by using the unsafe
and fixed
keywords. Unmanaged memory can be used to optimize performance in critical code sections, but requires careful handling to avoid memory leaks or errors.
Unity has basically no unmanaged memory and also the garbage collector works a bit differently, so you should just rely on yourself and understand how managed memory works on a basic level to know under what conditions it will be cleared and under what conditions it won't.
Using data structures wisely
Choosing an appropriate data structure is a key aspect of memory optimization. Instead of using complex objects and collections, which may consume more memory due to additional metadata and management information, you should prefer simple data structures such as arrays, lists, and structs.
Arrays and Lists
Let's look at an example:
// Uses more memory
List<string> names = new List<string>();
names.Add("John");
names.Add("Doe");
// Uses less memory
string[] names = new string[2];
names[0] = "John";
names[1] = "Doe";
In this example, the string[]
array requires less memory compared to List<string>
because it has no additional data structure to manage dynamic resizing.
However, that doesn't mean you should always use arrays instead of lists. You should realize that if you often have to add new elements and rebuild the array, or perform heavy searches that are already provided in the list, it is better to choose the second option.
Structs vs Classes
In my understanding, classes and structures are quite similar to each other, albeit with some differences (but that's not what this article will be about), they still have quite a big difference about how they are arranged in our application's memory. And understanding this can save you a huge amount of execution time and RAM, especially on large amounts of data. So let's look at some examples.
So, suppose we have a class with arrays and a structure with arrays. In the first case, the arrays will be stored in the RAM of our application, and in the second case, in the processor cache (taking into account some peculiarities of garbage collection, which we will discuss below). If we store data in the CPU cache, we speed up access to the data we need, in some cases from 10 to 100 times (of course, everything depends on the peculiarities of the CPU and RAM, and these days CPUs have become much smarter friends with compilers, providing a more efficient approach to memory management).
So, over time, as we populate or organize our class, the data will no longer be placed with each other in memory due to the heap handling features, because our class is a reference type and it is arranged more chaotically in memory locations. Over time, memory fragmentation makes it more difficult for the CPU to move data into the cache, which creates some performance and access speed issues with that very data.
// Class Array Data
internal class ClassArrayData
{
public int value;
}
// Struct Array Data
internal struct StructArrayData
{
public int value;
}
Let's look at the options of when we should use classes and when we should use structures.
When you shouldn't replace classes with structures:
You are working with small arrays. You need a reasonably big array for it to be measurable.
You have too big pieces of data. The CPU cannot cache enough of it, and it ends in RAM.
You have reference types like String in your Struct. They can point to RAM just like Class.
You don’t use the array enough. We need fragmentation for this to work.
You are using an advanced collection like List. We need fixed memory allocation.
You are not accessing the array directly. If you want to pass the data around to functions, use a Class.
If you are not sure, a bad implementation can be worse than just keeping to a Class array.
You still want Class functionality. Do not make hacky code because you want both Class functionality and Struct performance.
When it's still worth replacing a class with a structure:
Water simulation where you have a big array of velocity vectors.
City building game with a lot of game objects that have the same behavior. Like cars.
Real-time particle system.
CPU rendering using a big array of pixels.
A 90% boost is a lot, so if it sounds like something for you, I highly recommend doing some tests yourself. I would also like to point out that we can only make assumptions based on the industry norms because we are down at the hardware level.
I also want to give an example of benchmarks with mixed elements of arrays based on classes and structures (done on Intel Core i5-11260H 2.6 HHz, iteratively on 100 million operations with 5 attempts):
Struct (ms) | Class (ms) | Shuffle Value |
115ms | 155ms | No Shuffle |
105ms | 620ms | 10% Shuffle |
120ms | 840ms | 25% Shuffle |
125ms | 1050ms | 50% Shuffle |
140ms | 1300ms | 100% Shuffle |
Yes, we are talking about huge amounts of data here, but what I wanted to emphasize here is that the compiler cannot guess how you want to use this data, unlike you - and it is up to you to decide how you want to access it first.
Avoid memory leaks
Memory leaks can occur due to careless handling of objects and object references. In C#, the garbage collector automatically frees memory when an object is no longer used, but if there are references to objects that remain in memory, they will not be removed.
Memory Leak Code Examples
When working with managed resources such as files, network connections, or databases, make sure that they are properly released after use. Otherwise, this may result in memory leaks or exhaustion of system resources.
So, let's look at example of Memory Leak Code in C#:
public class MemoryLeakSample
{
public static void Main()
{
while (true)
{
Thread thread = new Thread(new ThreadStart(StartThread));
thread.Start();
}
}
public static void StartThread()
{
Thread.CurrentThread.Join();
}
}
And Memory Leak Code in Unity:
int frameNumber = 0;
WebCamTexture wct;
Texture2D frame;
void Start()
{
frameNumber = 0;
wct = new WebCamTexture(WebCamTexture.devices[0].name, 1280, 720, 30);
Renderer renderer = GetComponent<Renderer>();
renderer.material.mainTexture = wct;
wct.Play();
frame = new Texture2D(wct.width, wct.height);
}
// Update is called once per frame
// This code in update() also leaks memory
void Update()
{
if (wct.didUpdateThisFrame == false)
return;
++frameNumber;
//Check when camera texture size changes then resize your frame too
if (frame.width != wct.width || frame.height != wct.height)
{
frame.Resize(wct.width, wct.height);
}
frame.SetPixels(wct.GetPixels());
frame.Apply();
}
There are many ways to avoid memory leak in C#. We can avoid memory leak while working with unmanaged resources with the help of the ‘using’ statement, which internally calls Dispose() method. The syntax for the ‘using’ statement is as follows:
// Variant with Disposable Classes
using(var ourObject = new OurDisposableClass)
{
//user code
}
When using managed resources, such as databases or network connections, it is also recommended to use connection pools to reduce the overhead of creating and destroying resources.
Optimization of work with large volumes of data
When working with large amounts of data, it is important to avoid unnecessary copying and use efficient data structures. For example, if you need to manipulate large strings of text, use StringBuilder
instead of regular strings to avoid unnecessary memory allocations.
// Bad Variant
string result = "";
for (int i = 0; i < 10000; i++) {
result += i.ToString();
}
// Good Variant
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.Append(i);
}
string result = sb.ToString();
You should also avoid unnecessary memory allocations when working with collections. For example, if you use LINQ to filter a list, you can convert the result to an array using the ToArray()
method to avoid creating an unnecessary list.
// Bad Example
List<int> numbers = Enumerable.Range(1, 10000).ToList();
List<int> evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
// Good Example
int[] numbers = Enumerable.Range(1, 10000).ToArray();
int[] evenNumbers = numbers.Where(n => n % 2 == 0).ToArray();
Code profiling and optimization
Code profiling allows you to identify bottlenecks and optimize them to improve performance and memory efficiency. There are many profiling tools for C#, such as dotTrace, ANTS Performance Profiler and Visual Studio Profiler.
Unity has own Memory Profiler. You can read more about them here.
Profiling allows you to:
Identify code sections that consume the most memory.
Identify memory leaks and unnecessary allocations.
Optimize algorithms and data structures to reduce memory consumption.
Optimize applications for specific scenarios
Depending on the specific usage scenarios of your application, some optimization strategies may be more or less appropriate. For example, if your application runs in real time (like games), you may encounter performance issues due to garbage collection, and you may need to use specialized data structures or algorithms to deal with this problem (for example Unity DOTS and Burst Compiler).
Optimization with managed memory (unsafe code)
Although the use of unsafe
memory in C# should be cautious and limited, there are scenarios where using unsafe
code can significantly improve performance. This can be particularly useful when working with large amounts of data or when writing low-level algorithms where the overhead of garbage collection becomes significant.
// Unsafe Code Example
unsafe
{
int x = 10;
int* ptr;
ptr = &x;
// displaying value of x using pointer
Console.WriteLine("Inside the unsafe code block");
Console.WriteLine("The value of x is " + *ptr);
} // end unsafe block
Console.WriteLine("\nOutside the unsafe code block");
However, using unsafe
code requires a serious understanding of the inner workings of memory and multithreading in .NET, and requires extra precautions such as checking array bounds and handling pointers with care.
Conclusion
Memory optimization in C# is a critical aspect of developing efficient and fast applications. Understanding the basic principles of memory management, choosing the right data structures and algorithms, and using profiling tools will help you create an efficient application that utilizes system resources efficiently and provides high performance.
However, don't forget that in addition to code optimization, you should also optimize application resources (for example, this is very true for games, where you need to work with texture compression, frame rendering optimization, dynamic loading and unloading of resources using Bundles, etc.).
And of course thank you for reading the article, I would be happy to discuss various aspects of optimization and code with you.
You can also support writing tutorials, articles and see ready-made solutions for your projects:
My Discord | My Blog | My GitHub | Buy me a Beer
BTC: bc1qef2d34r4xkrm48zknjdjt7c0ea92ay9m2a7q55
ETH: 0x1112a2Ef850711DF4dE9c432376F255f416ef5d0