Search Unity

Showcase Performance tips

Discussion in 'Scripting' started by DevDunk, Sep 15, 2022.

  1. DevDunk

    DevDunk

    Joined:
    Feb 13, 2020
    Posts:
    5,059
    Some script changes which might help you write more performance code.

    Speed comparison:
    upload_2022-9-15_10-31-54.png


    Code:
    Code (CSharp):
    1. using System.Collections.Generic;
    2. using UnityEngine;
    3. using UnityEngine.Profiling;
    4. using System.Linq;
    5.  
    6. public class Benchmark : MonoBehaviour
    7. {
    8.     int count = 1_000_000;
    9.     string var1 = "abcE";
    10.     List<int> var3;
    11.     int[] var4;
    12.  
    13.     // Start is called before the first frame update
    14.     void Start()
    15.     {
    16.         var3 = Enumerable.Range(0, 1000).ToList();
    17.         var4 = Enumerable.Range(0, 5).ToArray();
    18.  
    19.         Profiler.BeginSample("1 SetTransformDouble");
    20.         for (int i = 0; i < count; i++)
    21.         {
    22.             transform.position = new Vector3(Random.Range(-100, 100), Random.Range(-100, 100), Random.Range(-100, 100));
    23.             transform.rotation = Random.rotation;
    24.         }
    25.         Profiler.EndSample();
    26.  
    27.         Profiler.BeginSample("1 SetTransformCombined");
    28.         for (int i = 0; i < count; i++)
    29.         {
    30.             transform.SetPositionAndRotation(new Vector3(Random.Range(-100, 100), Random.Range(-100, 100), Random.Range(-100, 100)), Random.rotation);
    31.         }
    32.         Profiler.EndSample();
    33.  
    34.         Profiler.BeginSample("1 DoubleTransformLocal");
    35.         for (int i = 0; i < count; i++)
    36.         {
    37.             transform.localPosition = new Vector3(Random.Range(-100, 100), Random.Range(-100, 100), Random.Range(-100, 100));
    38.             transform.localRotation = Random.rotation;
    39.         }
    40.         Profiler.EndSample();
    41.  
    42.         AudioSource mf;
    43.         bool temp = false;
    44.         Profiler.BeginSample("2 tryGetComponentFalse");
    45.         for (int i = 0; i < count; i++)
    46.         {
    47.             if (TryGetComponent<AudioSource>(out mf))
    48.             {
    49.                 temp = true;
    50.             }
    51.         }
    52.         Profiler.EndSample();
    53.  
    54.         Profiler.BeginSample("2 GetComponentFalse");
    55.         for (int i = 0; i < count; i++)
    56.         {
    57.             mf = GetComponent<AudioSource>();
    58.             if (mf != null)
    59.             {
    60.                 temp = true;
    61.             }
    62.         }
    63.         Profiler.EndSample();
    64.  
    65.         gameObject.AddComponent<AudioSource>();
    66.  
    67.         Profiler.BeginSample("2 tryGetTrue");
    68.         for (int i = 0; i < count; i++)
    69.         {
    70.             if (TryGetComponent<AudioSource>(out mf))
    71.             {
    72.                 temp = true;
    73.             }
    74.         }
    75.         Profiler.EndSample();
    76.  
    77.         Profiler.BeginSample("2 GetComponentTrue");
    78.         for (int i = 0; i < count; i++)
    79.         {
    80.             mf = GetComponent<AudioSource>();
    81.             if (mf != null)
    82.             {
    83.                 temp = true;
    84.             }
    85.         }
    86.         Profiler.EndSample();
    87.  
    88.         Profiler.BeginSample("3 string: ==");
    89.         for (int i = 0; i < count; i++)
    90.         {
    91.             if (var1 == string.Empty)
    92.             {
    93.  
    94.             }
    95.         }
    96.         Profiler.EndSample();
    97.  
    98.         Profiler.BeginSample("3 string: == blank");
    99.         for (int i = 0; i < count; i++)
    100.         {
    101.             if (var1 == "")
    102.             {
    103.  
    104.             }
    105.         }
    106.         Profiler.EndSample();
    107.  
    108.         Profiler.BeginSample("3 string .isnullorempty");
    109.         for (int i = 0; i < count; i++)
    110.         {
    111.             if (string.IsNullOrEmpty(var1))
    112.             {
    113.  
    114.             }
    115.  
    116.         }
    117.         Profiler.EndSample();
    118.  
    119.         Profiler.BeginSample("3 string .length null check");
    120.         for (int i = 0; i < count; i++)
    121.         {
    122.             if (var1 != null && var1.Length == 0)
    123.             {
    124.  
    125.             }
    126.  
    127.         }
    128.         Profiler.EndSample();
    129.  
    130.         Profiler.BeginSample("3 string ?.lenght");
    131.         for (int i = 0; i < count; i++)
    132.         {
    133.             if (var1?.Length == 0)
    134.             {
    135.  
    136.             }
    137.  
    138.         }
    139.         Profiler.EndSample();
    140.  
    141.         Profiler.BeginSample("3 string .lenght");
    142.         for (int i = 0; i < count; i++)
    143.         {
    144.             if(var1.Length == 0)
    145.             {
    146.  
    147.             }
    148.  
    149.         }
    150.         Profiler.EndSample();
    151.  
    152.         Profiler.BeginSample("4 native count");
    153.         for (int i = 0; i < count; i++)
    154.         {
    155.             int count = var3.Count;
    156.  
    157.         }
    158.         Profiler.EndSample();
    159.  
    160.         Profiler.BeginSample("4 LINQ count");
    161.         for (int i = 0; i < count; i++)
    162.         {
    163.             int count = var3.Count();
    164.  
    165.  
    166.         }
    167.         Profiler.EndSample();
    168.  
    169.         Profiler.BeginSample("5 AddRange");
    170.         for (int i = 0; i < count; i++)
    171.         {
    172.             var3.AddRange(var4);
    173.         }
    174.         Profiler.EndSample();
    175.  
    176.         Profiler.BeginSample("5 Add looped");
    177.         for (int i = 0; i < count; i++)
    178.         {
    179.             for (int j = 0; j < var4.Length; j++)
    180.             {
    181.                 var3.Add(var4[j]);
    182.             }
    183.  
    184.         }
    185.         Profiler.EndSample();
    186.  
    187.         System.GC.Collect();
    188.     }
    189. }
    190.  
    Feel free to add to discuss in replies
     

    Attached Files:

    mopthrow and Nefisto like this.
  2. karliss_coldwild

    karliss_coldwild

    Joined:
    Oct 1, 2020
    Posts:
    602
    I consider your results for add looped vs AddRange highly flawed. You are reusing var3 array not only between iterations but also between measuring of different approaches. Cost of list.Add or list.AddRange highly depends how many elements and how you added before. For comparison like this it is also important how many elements you are adding. Since in theory AddRange has advantage that it knows how many elements you are going to add while Add has to guess whether you are immediately going to repeat Add few hundred times or maybe none at all for long time. There is also a potential for difference due builtin function potentially copying whole range of memory directly instead of naive looping within code.

    The way dynamic array structures like System.Generic.List<T> are typically implemented is by having an amount of reserved memory which might be higher than the size reported by Count. Sometimes you can even query it with Capacity property. That way when you do Add, if list has unused capacity it only needs to increase Count and copy only the new element without any memory allocations. If list implementation didn't do this each Add would require doing a memory allocation and copying all the existing elements each time you add one more of them. Only when you try to Add new element but there is no unused capacity, the list will have to be reallocated and existing elements moved/copied. To reduce the amount of memory allocations and times existing list items need to be moved, many list implementations increase the capacity based on current size. The bigger the current size of list, the more extra capacity it will reserve it needs more. It is easiest to understand with multiplier of 2, but math for geometric progression works similarly with any multiplier > 1. The exact constant will vary between different implementations as there is tradeoff between less reallocations and more memory overhead consumed by unused capacity.

    Example assuming multiplier 2 (mono used by Unity might have different constant):
    Code (CSharp):
    1. var x = new List<int>(); // count=0, capacity = 0
    2. x.Add(1); //          allocate, count = 1, capacity = 1
    3. x.Add(1); //          allocate, count = 2, capacity = 2
    4. x.Add(1); //          allocate, count = 3, capacity = 4
    5. x.Add(1); // no allocation, count = 4, capacity = 4
    6. x.Add(1); //          allocate, count = 5, capacity = 8
    7. x.Add(1); //  no allocation, count = 6, capacity = 8
    8. x.Add(1); //   no allocation, count = 7, capacity = 8
    9. x.Add(1); //    no allocation, count = 8, capacity = 8
    10. x.Add(1); //          allocate, count = 9, capacity = 16
    11. x.Add(1); //  no allocation, count = 10, capacity = 16
    12. x.Add(1); //  no allocation, count = 11, capacity = 16
    13. x.Add(1); //  no allocation, count = 12, capacity = 16
    14. x.Add(1); //  no allocation, count = 13, capacity = 16
    15.  
    Due to the way geometric progression sum works total amount of item copy operations to build list of size N will be 2N and on average each element copied 2 times. If the capacity multiplier was smaller than 2, the average amount of copies would be slightly higher. On the other hand if list increased size only by one or some fixed count, total amount of copy operations would be on the order of N^2.

    The whole capacity strategy also means that inserting elements after
    x = new List<int>(n)
    and
    x.Clear()
    can have huge performance difference. The docs for List.Clear says that it reduces count to 0, but the Capacity remains unchanged. This means that inserting new elements into list that was previously large and then emptied with clear can be much faster than if the list was cleared by creating new empty list. If you are trying to compare performance of how different insertion strategies, you might not be able to observe differences caused by different reallocation behavior if list was empties with Clear() and still has capacity from previous runs.

    So here are my modified version for benchmarking Add vs AddRange

    Code (CSharp):
    1.  
    2. using System.Collections;
    3. using System.Collections.Generic;
    4. using UnityEngine;
    5. using UnityEngine.Profiling;
    6. using System.Linq;
    7. public class Benchmark : MonoBehaviour
    8. {
    9. int count = 1_000_00;
    10. string var1 = "abcE";
    11. List<int> var3;
    12. int[] var4;
    13. // Start is called before the first frame update
    14. public void Go()
    15. {
    16. var3 = Enumerable.Range(0, 1000).ToList();
    17. var4 = Enumerable.Range(0, 5).ToArray();
    18. UnityEngine.Profiling.Profiler.BeginSample("5 AddRange");
    19. var3 = new List<int>();
    20. for (int i = 0; i < count; i++)
    21. {
    22. var3.AddRange(var4);
    23. }
    24. UnityEngine.Profiling.Profiler.EndSample();
    25. UnityEngine.Profiling.Profiler.BeginSample("5 Add looped");
    26. var3 = new List<int>();
    27. for (int i = 0; i < count; i++)
    28. {
    29. for (int j = 0; j < var4.Length; j++)
    30. {
    31. var3.Add(var4[j]);
    32. }
    33. }
    34. UnityEngine.Profiling.Profiler.EndSample();
    35.  
    36.  
    37. UnityEngine.Profiling.Profiler.BeginSample("5 AddRange clear");
    38. var3 = new List<int>();
    39. for (int i = 0; i < count; i++)
    40. {
    41. var3.Clear();
    42. var3.AddRange(var4);
    43. }
    44. UnityEngine.Profiling.Profiler.EndSample();
    45. UnityEngine.Profiling.Profiler.BeginSample("5 Add looped clear");
    46. var3 = new List<int>();
    47. for (int i = 0; i < count; i++)
    48. {
    49. var3.Clear();
    50. for (int j = 0; j < var4.Length; j++)
    51. {
    52. var3.Add(var4[j]);
    53. }
    54. }
    55. UnityEngine.Profiling.Profiler.EndSample();
    56.  
    57.  
    58. UnityEngine.Profiling.Profiler.BeginSample("5 AddRange new empty");
    59. var3 = new List<int>();
    60. for (int i = 0; i < count; i++)
    61. {
    62. var3 = new List<int>();
    63. var3.AddRange(var4);
    64. }
    65. UnityEngine.Profiling.Profiler.EndSample();
    66. UnityEngine.Profiling.Profiler.BeginSample("5 Add looped new empty");
    67. var3 = new List<int>();
    68. for (int i = 0; i < count; i++)
    69. {
    70. var3 = new List<int>();
    71. for (int j = 0; j < var4.Length; j++)
    72. {
    73. var3.Add(var4[j]);
    74. }
    75. }
    76. UnityEngine.Profiling.Profiler.EndSample();
    77.  
    78.  
    79. var var400 = Enumerable.Range(0, 103).ToArray();
    80. UnityEngine.Profiling.Profiler.BeginSample("103 AddRange");
    81. var3 = new List<int>();
    82. for (int i = 0; i < count; i++)
    83. {
    84. var3.AddRange(var400);
    85. }
    86. UnityEngine.Profiling.Profiler.EndSample();
    87. UnityEngine.Profiling.Profiler.BeginSample("103 Add looped");
    88. var3 = new List<int>();
    89. for (int i = 0; i < count; i++)
    90. {
    91. for (int j = 0; j < var400.Length; j++)
    92. {
    93. var3.Add(var400[j]);
    94. }
    95. }
    96. UnityEngine.Profiling.Profiler.EndSample();
    97.  
    98.  
    99. UnityEngine.Profiling.Profiler.BeginSample("103 AddRange clear");
    100. var3 = new List<int>();
    101. for (int i = 0; i < count; i++)
    102. {
    103. var3.Clear();
    104. var3.AddRange(var400);
    105. }
    106. UnityEngine.Profiling.Profiler.EndSample();
    107. UnityEngine.Profiling.Profiler.BeginSample("103 Add looped clear");
    108. var3 = new List<int>();
    109. for (int i = 0; i < count; i++)
    110. {
    111. var3.Clear();
    112. for (int j = 0; j < var400.Length; j++)
    113. {
    114. var3.Add(var400[j]);
    115. }
    116. }
    117. UnityEngine.Profiling.Profiler.EndSample();
    118.  
    119. UnityEngine.Profiling.Profiler.BeginSample("103 AddRange new");
    120. var3 = new List<int>();
    121. for (int i = 0; i < count; i++)
    122. {
    123. var3 = new List<int>();
    124. var3.AddRange(var400);
    125. }
    126. UnityEngine.Profiling.Profiler.EndSample();
    127. UnityEngine.Profiling.Profiler.BeginSample("103 Add looped new");
    128. var3 = new List<int>();
    129. for (int i = 0; i < count; i++)
    130. {
    131. var3 = new List<int>();
    132. for (int j = 0; j < var400.Length; j++)
    133. {
    134. var3.Add(var400[j]);
    135. }
    136. }
    137. UnityEngine.Profiling.Profiler.EndSample();
    138.  
    139. System.GC.Collect();
    140. }
    141. }
    142.  
    upload_2022-9-15_13-22-23.png
    The main takeaway from that shouldn't be whether Add or AddRange is better. But that things can be a lot more complex than simply X is better than Y. Don't trust random post on the internet from N years ago that doing X is faster than Y even if they are backed by some benchmark results. Things might be different with different usage patterns, or maybe you are using newer Unity with newer more optimized C# runtimer, or maybe you are targeting mobile platform which uses il2cpp instead of mono.
    Make your own benchmarks and do it with the data and usage patterns that represents your usecase. What's "better" might depend on factors that you can't imagine so when creating artificial microbenchmark which doesn't properly take it into account it may give impressions that the change will make things 3 times faster but in reality it makes them 9 times slower.


    One more interesting aspect to watchout while doing benchmarks is the cost of profiling process. Not sure if it's enabled by default or whether I had it enabled from previous profiling, but Unity can record callstack when a garbage collectable allocation is performed. Recording callstack is a costly operations especially in microbenchmarks like these. I observed up to 20%-50% of time being consumed by profiler recordoing callstack instead the stuff you are trying to profile. At that point you are not measuring how long the code runs, but how many times it causes profiler to record callstack. It had a large impact on the order of results for different test cases.

    upload_2022-9-15_14-7-32.png
     
    Last edited: Sep 15, 2022
    mopthrow and DevDunk like this.
  3. DevDunk

    DevDunk

    Joined:
    Feb 13, 2020
    Posts:
    5,059
    Amazing reply! Will definitely take this with me for future work!