Unity GC optimization

Time:2022-11-20

foreword

When the game is running, the data is mainly stored in the memory. When the game data is no longer needed, the memory storing the current data can be recycled for reuse. Memory garbage refers to the memory occupied by the currently discarded data, and garbage collection (GC) refers to the process of recycling discarded memory for reuse.

Unity regards garbage collection as a part of memory management. If the discarded data in the game occupies a large amount of memory, the performance of the game will be greatly affected. At this time, garbage collection will become a major obstacle to game performance.

In this article, we mainly learn the mechanism of garbage collection, how garbage collection is triggered and how to improve GC efficiency to improve game performance.

Introduction to Unity memory management mechanism

To understand how garbage collection works and when it is triggered, we first need to understand Unity’s memory management mechanism. Unity mainly adopts the mechanism of automatic memory management. During development, there is no need to tell Unity in detail how to manage memory in the code. Unity will manage memory internally. Compared with the need to manage memory at any time when using C++ development, this has certain advantages. Of course, the disadvantage is that you need to pay attention to the growth of memory at any time, and don’t let the game run “fly” on the phone.

Unity’s automatic memory management can be understood as the following parts:

1. There are two memory management pools inside Unity: heap memory and stack memory. Stack memory (stack) is mainly used to store small and short-lived data, and heap memory (heap) is mainly used to store large and long-term data.

2. Variables in Unity will only be allocated on the stack or heap memory. Variables are either stored on the stack memory or on the heap memory.

3. As long as the variable is active, the memory occupied by it will be marked as used, and the memory of this part will be allocated.

4. Once the variable is no longer activated, the memory it occupies is no longer needed, and this part of the memory can be recycled to the memory pool for reuse. This operation is memory recycling. The memory on the stack is reclaimed very quickly, and the memory on the heap is not reclaimed in time, and the corresponding memory will still be marked as used.

5. Garbage collection mainly refers to the memory allocation and recovery on the heap. Unity will periodically perform GC operations on the heap memory.

After understanding the process of GC, let’s learn more about the difference between the allocation and recovery mechanisms of heap memory and stack memory.

Stack memory allocation and recycling mechanism

Allocation and deallocation of memory on the stack is fast and easy because only short-lived or small variables are stored on the stack. Memory allocation and deallocation are done in a sequential and size-controlled manner.

The stack works like a stack: its essence is just a collection of data, and the data enters and exits in a fixed way. It’s this simplicity and immobility that makes working with stacks so fast. When data is stored on the stack, it simply needs to be extended afterwards. When data becomes invalid, it just needs to be removed from the stack.

Heap memory allocation and recycling mechanism

The memory allocation and storage on the heap memory is relatively more complicated, mainly because short-term and small data can be stored on the heap memory, and data of various types and sizes can also be stored. The sequence of memory allocation and recovery on it is not controllable, and it may be required to allocate memory units of different sizes to store data.

When variables on the heap are stored, there are mainly the following steps:

1. First, Unity detects whether there are enough idle memory units to store data, and if so, allocates memory units of the corresponding size;

2. If there are not enough storage units, Unity will trigger garbage collection to release heap memory that is no longer used. This operation is a slow operation, if there are memory units of sufficient size after garbage collection, memory allocation is performed.

3. If there are not enough memory units after garbage collection, Unity will expand the size of the heap memory, which will be very slow, and then allocate memory units of the corresponding size to variables.

The allocation of heap memory can become very slow, especially in the case of garbage collection and heap memory expansion, and it is usually necessary to reduce the number of such operations.

Actions during garbage collection

When a variable on the heap memory is no longer active, the memory it occupies will not be reclaimed immediately, and the unused memory will only be reclaimed during GC.

Every time the GC is run, the following operations are mainly performed:

1. GC will check every storage variable on the heap memory;

2. For each variable, it will detect whether its reference is active;

3. If the variable reference is no longer active, it will be marked as recyclable;

4. The marked variable will be removed, and the memory it occupies will be recycled to the heap memory.

GC operation is an extremely costly operation. The more variables or references there are in the heap memory, the more operations it will run and the longer it will take.

When will garbage collection be triggered

There are three main operations that trigger garbage collection:

1. When the memory allocation operation is performed on the heap memory and the memory is insufficient, garbage collection will be triggered to utilize the idle memory
2. GC will be triggered automatically, and the running frequency of different platforms is different
3. GC can be enforced

Especially when there are not enough memory units for memory allocation on the heap memory, GC will be triggered frequently, which means that frequent memory allocation and recycling on the heap memory will trigger frequent GC operations.

Problems caused by GC operations

After understanding the role of GC in Unity memory management, we need to consider the problems it brings. The most obvious problem is that GC operations will take a lot of time to run. If there are a large number of variables or references on the heap that need to be checked, the checking operation will be very slow, which will make the game run slowly. Secondly, GC may run at critical times, for example, when the CPU is at a critical moment in the performance of the game. At this time, any additional operation may have a great impact, causing the game frame rate to drop.

Another problem caused by GC is the fragmentation of heap memory. When a memory unit is allocated from the heap, its size depends on the size of the variable it stores. When the memory is reclaimed to the heap memory, it may cause the heap memory to be divided into fragmented units. That is to say, the memory unit that can be used by the heap memory is larger overall, but the individual memory unit is smaller, and a storage unit of a suitable size cannot be found during the next memory allocation, which will also trigger a GC operation or a heap memory expansion operation.

Heap memory fragmentation will cause two results, one is that the memory occupied by the game will become larger and larger, and the other is that GC will be triggered more frequently.

Analyze the problems caused by GC

The problems caused by GC operations are mainly manifested in low frame rate operation, intermittent performance interruption or reduction. If the game has such performance, you first need to open the profiler window in Unity to determine whether it is caused by GC.

Analyze heap memory allocation

If GC causes game performance problems, we need to know which part of the code in the game will cause GC. Memory garbage is generated when variables are no longer active, so first we need to know what variables are allocated on the heap memory.

Variable types for heap memory and stack memory allocation

In Unity, value type variables are allocated on the stack, and other types of variables are allocated on the heap.

The following code can be used to understand the allocation and release of value types, and the corresponding variables will be recycled immediately after the function call:

void ExampleFunciton()
{
    int localInt = 5;
} 

The reference codes of the corresponding reference types are as follows, and the corresponding variables are recycled only during GC:

void ExampleFunction()  
{  
    List localList = new List();   
}  
Use Profiler Window to detect heap memory allocation:

We can check the heap memory allocation operation in the profier window: in the CPU usage analysis window, we can detect the memory allocation of any frame of CPU. One of the options is GC Alloc, which can be analyzed to locate what function is causing a large number of heap memory allocation operations. Once the function is located, we can analyze and solve the cause of the problem to reduce the generation of memory garbage. The current version of Unity5.5 also provides a deep profiler to deeply analyze the generation of GC garbage.

Ways to reduce the impact of GC

In general, we can reduce the impact of GC in three ways:

1. Reduce the number of GC runs;
2. Reduce the running time of a single GC;
3. Delay the running time of GC to avoid triggering at critical times, for example, GC can be called when the scene is loaded

Seems simple enough, and based on that, we can employ three strategies:

1. Refactor the game to reduce the allocation of heap memory and the allocation of references. Fewer variables and references will reduce the number of detections in GC operations and improve the efficiency of GC operations
2. Reduce the frequency of heap memory allocation and recovery, especially at critical moments. That is to say, fewer events trigger GC operations, and also reduce the fragmentation of heap memory
3. We can try to measure the time of GC and heap memory expansion so that it executes in a predictable order. Of course, this operation is extremely difficult, but it will greatly reduce the impact of GC

Reduce the amount of memory garbage

Reducing memory garbage can mainly be reduced through some methods:

  • cache data

If some functions that cause heap memory allocation are repeatedly called in the code but the return result is not used, this will cause unnecessary memory garbage. We can cache these variables for reuse. This is the cache.

For example, the following code will cause heap memory allocation every time it is called, mainly because it will allocate a new array every time:

void OnTriggerEnter(Collider other)  
{  
     Renderer[] allRenderers = FindObjectsOfType<Renderer>();  
     ExampleFunction(allRenderers);   
}

Compared with the following code, only one array will be generated to cache data, which can be reused without causing more memory garbage:

private Renderer[] allRenderers;  
    
void Start()  
{
    allRenderers = FindObjectsOfType<Renderer>();  
}  
    
void OnTriggerEnter(Collider other)  
{
    ExampleFunction(allRenderers);  
} 
  • Do not repeatedly allocate heap memory in frequently called functions

existMonoBehaviour, if we need to allocate heap memory, the worst case is to allocate heap memory in a function that is called repeatedly, for exampleUpdate()andLateUpdate()Functions are functions that are called every frame, which can cause a lot of memory garbage. we can considerStart()orAwake()Memory allocation is performed in the function, which can reduce memory garbage.

In the following example, the Update function will trigger the generation of memory garbage multiple times:

void Update()  
{
    ExampleGarbageGenerationFunction(transform.position.x);
} 

With one simple change, we can ensure that the function call is triggered every time x changes, thus avoiding heap allocations every frame:

private float previousTransformPositionX;   
void Update()  
{
    float transformPositionX = transform.position.x;  
    if(transfromPositionX != previousTransformPositionX)  
    {
        ExampleGarbageGenerationFunction(transformPositionX);   
        previousTransformPositionX = trasnformPositionX;
    }  
}  

Another way is to use timers in Update, especially in code that runs regularly but does not need to run every frame, for example:

void Update()  
{
    ExampleGarbageGeneratiingFunction()  
}  

By adding a timer, we can ensure that the function is only triggered every 1s:

private float timeSinceLastCalled;  
private float delay = 1f;  
void Update()  
{  
  timSinceLastCalled += Time.deltaTime;  
  if(timeSinceLastCalled > delay)  
  {  
    ExampleGarbageGenerationFunction();  
    timeSinceLastCalled = 0f;  
  }  
}  

With such small changes, we can make the code run faster and reduce the generation of memory garbage.

Attachment: Don’t ignore this method. In the recent project performance optimization, I often use this method to optimize the performance of the game. In many fixed-time event callback functions, if a new cache is allocated every time, but in the operation It will not be released after completion, which will cause a lot of memory garbage. For such a cache, the best way is to clear it after the current cycle callback or mark it as obsolete.

  • clear linked list

When allocating the linked list on the heap memory, if the linked list needs to be allocated repeatedly, we can use the clear function of the linked list to clear the linked list instead of repeatedly creating the allocated linked list.

void Update()  
{  
  List myList = new List();  
    PopulateList(myList);   
}  

Through improvement, we can allocate heap memory only when the linked list is created for the first time or when the linked list must be reset, thus greatly reducing the generation of memory garbage:

  private List myList = new List();  
  void Update()  
  {  
    myList.Clear();  
    PopulateList(myList);  
  }
  • object pool

Even if we try to reduce the allocation of heap memory in the code as much as possible, if the game has a large number of objects that need to be created and destroyed, it will still cause GC. Object pool technology can reduce the frequency of heap memory allocation and recovery by reusing objects. Object pools are widely used in games, especially when the same game objects need to be created and destroyed frequently in the game, such as gun bullets, which are frequently generated and destroyed.

Factors that cause unnecessary heap memory allocations

We already know that value type variables are allocated on the stack, and other variables are allocated on the heap memory, but there are still some cases of heap memory allocation that will surprise us. Let’s analyze some common unnecessary heap memory allocation behaviors and optimize them.

  • string

In C#, a string is a reference type variable rather than a value type variable, even though it appears to store the value of the string. This means that strings can cause a certain amount of memory garbage, and since strings are often used in code, we need to be extra careful about them.

Strings in c# are immutable, which means that their internal values ​​cannot be changed after they are created. Every time when operating on a string (such as using the “+” operation of a string), Unity will create a new string to store the new string, so that the old string will be discarded, which will cause memory garbage .

Here are some ways we can minimize the impact of strings:

1. Reduce the creation of unnecessary strings. If a string is used multiple times, we can create and cache the string.
2. Reduce unnecessary string operations. For example, if in the Text component, some strings need to be changed frequently, but other parts will not, then we can divide it into two components, and for the unchanging part, just Just set it as a constant string, see the example below.
3. If we need to create strings in real time, we can useStringBuilderinstead,StringBuilderDesigned for memory allocations that don’t need to be done, thereby reducing memory garbage generated by strings.
4. Remove the in-gameDebug.Log()Code for a function that, although the output of the function may be empty, a call to the function is executed, and the function creates a string of at least one character (the null character). If there are a large number of calls to this function in the game, this will cause an increase in memory garbage.

In the code below, inUpdatefunction will perform astringSuch operations will cause unnecessary memory garbage:

public Text timerText;  
private float timer;  
void Update()  
{  
  timer += Time.deltaTime;  
  timerText.text = "Time:"\+ timer.ToString();  
}  

By separating the strings, we can eliminate the addition operation of the strings, thereby reducing unnecessary memory garbage:

public Text timerHeaderText;  
public Text timerValueText;  
private float timer;  
void Start()  
{  
  timerHeaderText.text = "TIME:";  
}  
  
void Update()  
{
    timerValueText.text = timer.ToString();  
}  
  • Unity function call

In code programming, when we call code that is not written by ourselves, whether it is Unity’s own or a plug-in, we may generate memory garbage. Some function calls of Unity will generate memory garbage, we need to pay attention to its use when using it.

There is no clear list here to point out which functions need attention. Each function has different uses in different situations, so it is best to analyze the game carefully to locate the cause of memory garbage and how to solve the problem. Sometimes caching is an effective way, sometimes it is a way to minimize the frequency of function calls, and sometimes it is a way to refactor code with other functions. Now let’s analyze the common function calls in Unity that cause heap memory allocation.

In Unity, if a function needs to return an array, a new array will be allocated for the result return, which is not easy to notice, especially if the function contains iterators, the following code will be used for each iterator Generate a new array:

void ExampleFunction()  
{  
  for(int i=0; i < myMesh.normals.Length;i++)  
  {  
    Vector3 normal = myMesh.normals[i];  
  }  
}  

For such problems, we can cache a reference to an array, so that we only need to allocate an array to achieve the same function, thereby reducing the generation of memory garbage:

void ExampleFunction()  
{  
  Vector3[] meshNormals = myMesh.normals;  
  for(int i=0; i < meshNormals.Length;i++)  
  {  
    Vector3 normal = meshNormals[i];
  }
}

In addition, another function calling GameObject.name or GameObject.tag will also cause unexpected heap memory allocation. Both functions will save the result as a new string and return, which will cause unnecessary memory garbage and affect the result. Caching is an effective way, but there are corresponding functions in Unity to replace it. For comparing gameObject tags, GameObject.CompareTag() can be used instead.

In the following code, calling gameobject.tag will generate memory garbage:

private string playerTag="Player";  
void OnTriggerEnter(Collider other)  
{  
  bool isPlayer = other.gameObject.tag == playerTag;  
}  

Using GameObject.CompareTag() can avoid the generation of memory garbage:

private string playerTag = "Player";  
void OnTriggerEnter(Collider other)  
{  
  bool isPlayer = other.gameObject.CompareTag(playerTag);  
} 

  
not justGameObject.CompareTag, many other functions in Unity can also avoid the generation of memory garbage. For example we can useInput.GetTouch()andInput.touchCountto replaceInput.touches, or usePhysics.SphereCastNonAlloc()to replacePhysics.SphereCastAll()

  • Packing operation

Boxing operation refers to the internal transformation process when a value type variable is used as a reference type variable. If we pass a value type to a function with an object type parameter, this will trigger the boxing operation. For example, the String.Format() function needs to pass in string and object type parameters. If string and int type data are passed in, the boxing operation will be triggered. As shown in the following code:

void ExampleFunction()  
{  
  int cost = 5;  
  string displayString = String.Format("Price:{0} gold",cost);  
}  

In Unity’s boxing operation, a reference of System.Object type will be allocated on the heap memory for the value type to encapsulate the value type variable, and the corresponding cache will generate memory garbage. Boxing operation is a very common behavior that generates memory garbage. Even if there is no direct boxing operation on variables in the code, it may be generated in plug-ins or other functions. The best solution is to avoid or remove the code that causes boxing as much as possible.

  • coroutine

Calling StartCoroutine() will generate a small amount of memory garbage, because Unity will generate entities to manage coroutines. So the call of this function should be limited at the critical moment of the game. Based on this, any coroutines called at critical moments in the game need special attention, especially coroutines that contain delayed callbacks.

yield does not generate heap memory allocation in the coroutine, but if yield returns with parameters, it will cause unnecessary memory garbage, for example:

yield return 0;

Due to the need to return 0, the boxing operation is triggered, so memory garbage will be generated. In this case, in order to avoid memory garbage, we can return like this:

yield return null;

Another wrong use of coroutines is to new the same variable every time it returns, for example:

while(!isComplete)  
{  
  yield return new WaitForSeconds(1f);  
}  

We can use caching to avoid such memory garbage generation:

WaitForSeconds delay = new WaiForSeconds(1f);  
while(!isComplete)  
{  
  yield return delay;  
}  

If the coroutines in the game generate memory garbage, we can consider other ways to replace the coroutines. Refactoring code is very complicated for games, but we can also pay attention to some common operations for coroutines. For example, if coroutines are used to manage time, it is best to keep a record of time in the update function. If coroutines are used to control the sequence of events in the game, it is best to have a certain way of communicating information between different events. For coroutines, there is no suitable method for every situation, and only the best solution can be selected according to the specific code.

  • foreach loop

In versions prior to Unity5.5, memory garbage will be generated in the iteration of foreach, mainly from the subsequent boxing operation. Every time when foreach iterates, a System.Object will be produced on the heap memory to implement the iterative loop operation. This problem was solved in Unity5.5. For example, in versions earlier than Unity5.5, loops were implemented with foreach:

void ExampleFunction(List listOfInts)  
{  
  foreach(int currentInt in listOfInts)  
  {  
    DoSomething(currentInt);  
  }  
}  

If the game project cannot be upgraded to above 5.5, you can use a for or while loop to solve this problem, so you can change it to:

void ExampleFunction(List listOfInts)  
{  
  for(int i=0; i < listOfInts.Count; i++)  
  {  
    int currentInt = listOfInts\[i\];  
    DoSomething(currentInt);  
  }  
}
  • function reference

A reference to a function, whether it points to an anonymous function or an explicit function, is a reference type variable in Unity, which will be allocated on the heap memory. After the anonymous function call is completed, the memory usage and heap memory allocation will be increased. The reference and termination of specific functions depends on the operating platform and compiler settings, but if you want to reduce GC, it is best to reduce the reference of functions.

  • LINQ and constant expressions

Since LINQ and constant expressions are implemented in a boxed manner, it is best to perform a performance test when using them.

Refactor the code to reduce the impact of GC

Even if we reduce the allocation operations of the code on the heap memory, the code will increase the workload of the GC. The most common way to increase the workload of the GC is to make it check objects that it doesn’t have to. Struct is a variable of value type, but if the struct contains variables of reference type, then the GC must detect the entire struct. If there are many such operations, the workload of GC will be greatly increased. In the following example the struct contains a string, then the whole struct must be checked in the GC:

public struct ItemData  
{  
  public string name;  
  public int cost;  
  public Vector3 position;  
}  
  
private ItemData[] itemData;  

We can split the struct into multiple arrays to reduce the workload of GC:

private string[] itemNames;  
private int[] itemCosts;  
private Vector3[] itemPositions;  

Another way to increase the workload of GC in the code is to save unnecessary Object references. During the GC operation, the object references on the heap memory will be checked. Fewer references means less checking workload. . In the following example, the current dialog contains a reference to the next dialog, which causes the GC to check the next object frame:

public class DialogData  
{  
  private DialogData nextDialog;  
  public DialogData GetNextDialog()  
  {  
    return nextDialog;  
  }  
}  

By refactoring the code, we can return the token of the next dialog entity instead of the dialog entity itself, so that there are no redundant object references, thereby reducing the workload of the GC:

public class DialogData  
{  
  private int nextDialogID;  
  public int GetNextDialogID()  
  {  
    return nextDialogID;  
  }  
}  

Of course, this example itself is not important, but if our game contains a large number of objects that contain references to other Objects, we can consider reducing the workload of GC by refactoring the code.

Actively invoke GC operations

If we know that the heap memory is not used after being allocated, we hope to call the GC operation actively, or when the GC operation does not affect the game experience (such as when the scene is switched), we can actively call the GC operation:

  System.GC.Collect();

Through active calls, we can actively drive GC operations to reclaim heap memory.

Summarize

Through this article, I have a certain understanding of GC in Unity, and have a certain understanding of the impact of GC on game performance and how to solve it. By locating the code causing GC issues and refactoring the code we can manage the game’s memory more efficiently.