In this tutorial, we will see how you can use dotMemory to optimize your application's memory usage.
What do we mean by "optimizing memory usage"? Like any process in the operating system, Garbage Collector (GC) consumes system resources. The logic is simple: the more collections GC has to make, the larger the CPU overhead and the poorer app performance. Typically, this happens when your app allocates a large number of objects that are required for some limited period of time.
To identify and analyze such issues, you should examine the so-called memory traffic. Traffic information shows you how many objects (and memory) were allocated and released during the selected timeframe.
Let’s see how we can determine excessive allocations in your app and get rid of them using dotMemory.
Traditionally, the sample app we’ll use for this tutorial is Conway’s Game of Life. Before you begin, please download and unpack this zip package with the app.
As the app works with a large number of objects (cells), it would be interesting to look at the dynamics of how these objects are allocated and collected.
- Open the Game of Life solution in Visual Studio.
- Run dotMemory using the menu dotMemory | Profile Startup Project.
- In the opened Profiler Configuraion window, turn on Start collecting allocation data immediately. This will tell dotMemory to start collecting profiling info right after the app is launched. This is how the window should look like after you specify the options:
- Click Run to start the profiling session. This will launch our app and open the main Analysis page in dotMemory:
* For convenience, you can hide the unmanaged part of memory by deselecting the *Show unmanaged memory *checkbox.
Switch to the dotMemory’s main window to see the timeline. The timeline shows you the memory usage of your app in real time. More specifically, it provides details on the current size of unmanaged memory*, Gen0, Gen1, Gen2 heaps and Large Object Heap. Up until Game of Life starts, memory consumption stands still:
After the app is launched, we can start getting memory snapshots. As we want to investigate the dynamics of how our app behaves, we need to take at least two snapshots. The timeframe between getting the snapshots will be the subject of further memory traffic analysis.
Naturally, both snapshots must be taken during that part of Game of Life's operation when the majority of allocations occur. Let’s take one snapshot at the 30th generation of the Game of Life, and the second one at the 100th generation.
- Start the game using the Start button in the app.
* You should take the snapshot at approximately that time. In this example, we just want to get a “big picture” of what is going on; there’s no need to be very precise.
Nevertheless, when it is required to take snapshots at exact places in the code, you can use dotMemory API.
When the Generations counter (in the top right-hand corner of our app) reaches 30*, click the Get Snapshot button in dotMemory.
If you now look at the timeline, you’ll see how the app consumes memory in real time. When the app allocates new objects, memory consumption increases (Gen0 diagram grows). When garbage collection takes place, memory consumption decreases. As a result, the timeline follows a saw like pattern.
- When the Generations counter reaches 100, get one more snapshot, again by using the Get Snapshot button in dotMemory.
- End the profiling session by closing the Game of Life app.
The main page now contains two snapshots:
Now, we’ll take a look at the memory traffic in the timeframe between getting the snapshots.
- Click Add to comparison for each snapshot to add them to the comparison area. The order in which you add snapshots is not important, as dotMemory always uses the older snapshot as the basis for comparison:
- Click View memory traffic in the comparison area:
This will open the Traffic view. The view currently shows us how many objects of a certain type were created between Snapshot #1 and Snapshot #2.
* When analyzing memory traffic, look not only at how many objects of a certain type were allocated, but also how many objects of this type were collected. The fact these parameters have almost identical values may imply sub-optimal memory usage. Chances are that you can improve your code and avoid these allocations.
Take a look at the list. 23 MB, or about 50% of the overall memory traffic, is due to the allocation of objects of the Cell class*. At the same time, most of these Cells – 22.7 MB – were collected as well. That's quite strange, since cells should exist for the whole duration of any Game of Life. There is no doubt that these collections are hurting our app's performance.
Let’s check where these Cell objects come from.
- Click the row with the GameOfLife.Cell class:
The list at the bottom of this screen shows us the function (back trace) that created the objects. Apparently, this is the CalculateNextGeneration method of the Grid class. Let’s find it in the code.
- Open the GameOfLife solution in Visual Studio.
- Open the Grid.cs file which contains the implementation of the Grid class:
- Locate the CalculateNextGeneration(int row, int column) method:
It appears that this method calculates and returns the Cell objects for each next generation of Game of Life. But this doesn’t explain high memory traffic. Let’s return to dotMemory and find out what function calls the CalculateNextGeneration method.
- In dotMemory, expand the CalculateNextGeneration method to see the next function in the stack.
It is the Update method of the Grid class:
- Find this method in the code:
This finally sheds light on the causes of our high memory traffic. There is the nextGenerationCells array of the Cell type which stores cells for the next generation of Game of Life. On each generation update, cells in this array are replaced with new ones. Cells left from previous generation are no longer needed and get collected by GC after some time.
Obviously, there’s no need to fill the nextGenerationCells array with new cells each time as the array exists during the entire lifetime of the app. To get rid of high memory traffic, we simply need to update the properties of existing cells with new values instead of creating new cells. Let’s do this in the code.
- Actually, as our app is a learning example, it already contains the required implementation of the CalculateNextGeneration method. This method updates a cell’s IsAlive and Age fields sent by reference:
To fix the issue, simply uncomment the lines in Update() that update the nextGenerationCells array using this method. Finally, the Update() method should look as follows:
Now, let's apply these changes and check how they affect the memory traffic.
- Build the app one more time. Repeat the steps described in Step 1. Running a Profiler and Step 2. Getting Snapshots to get two new snapshots.
- Open the Traffic view to see the memory traffic between the collected snapshots (as described in Sub-steps 1 and 2 in Step 3. Analyzing Memory Traffic):
The GameOfLife.Cell class is no longer on the list! This resulted in a 35% drop in the overall traffic (down to 34 MB), which is a very good optimization.