Search Unity

  1. Unity Asset Manager is now available in public beta. Try it out now and join the conversation here in the forums.
    Dismiss Notice

Mono Upgrade StringBuilder generating garbage .NET 4.x but not in .NET 3.5

Discussion in 'Experimental Scripting Previews' started by Gladyon, Feb 1, 2019.

  1. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    Very recently I went for .NET 4.x as .NET 3.5 is now depreciated.

    I had the bad surprise to see that some StringBuilder operations now generate garbage, where they didn't previously.
    The most trivial one is when you clear a StringBuilder, it is done now as it was before, by setting 'Length' to 0, but now it may generate some garbage because when there are several chunks the old ones are removed and a large new one is created (in order to keep the same capacity).

    It also seem that 'Append' generate more garbage than before, but it wouldn't be such a problem if 'Clear()' wasn't generating any.

    Even just creating a StringBuilder is enough to generate garbage:

    For information, the StringBuilder created here are empty, here is the code creating them:
    Code (CSharp):
    1. for (Int32 i=PoolLength; i<NewLength; i++)
    2. {
    3.     NewData[i] = new T();
    4. }
    5.  
    Where 'T' is 'StringBuilder' of course.


    I think that having StringBuilder generating more garbage than in the previous framework is a step backward.
     
  2. JoshPeterson

    JoshPeterson

    Unity Technologies

    Joined:
    Jul 21, 2014
    Posts:
    6,936
    Can you submit a bug report on this issue?
     
  3. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    I'm not sure it's a bug.
    I've looked into Microsoft's code for the 'StringBuilder.cs' file, and it does generate garbage.
    It comes from how the chunks are managed internally.

    If Unity is willing to replace the 'StringBuilder' implementation with one which is garbage-free then I'll be delighted.
    My post's goal was mainly to find out if some people had GC-free alternative to StringBuilder, in order to avoid re-inventing the wheel.


    But if you confirm that it is to be considered as a bug, then I'll post a bug report with a repro-project showing different cases where GC is generated when it could be avoided.
     
  4. JoshPeterson

    JoshPeterson

    Unity Technologies

    Joined:
    Jul 21, 2014
    Posts:
    6,936
    We'll need to investigate it a bit more, but we would like to avoid generation of garbage here if at all possible. The best way to handle this is via a bug report, even if this is not strictly a bug.
     
  5. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    OK, I've sent the bug report, the number is '1123117'.

    If someone is interested, here is the test script (just place it on the camera of an empty scene, start the project, wait 5 frames and then take a look at the profiler).
    Code (CSharp):
    1. using System;
    2. using System.Text;
    3.  
    4. using UnityEngine;
    5. using UnityEngine.Profiling;
    6.  
    7.  
    8. public class Test : MonoBehaviour
    9. {
    10.     static StringBuilder EmptyTest;
    11.     static StringBuilder First;
    12.     static StringBuilder Second;
    13.  
    14.     void Start()
    15.     {
    16.         // .NET 3.5: 48 bytes
    17.         // .NET 4.x: 112 bytes
    18.         // It shouldn't generate any garbage, only allocate some memory
    19.         Profiler.BeginSample("StringBuilder empty constructor");
    20.         EmptyTest = new StringBuilder();
    21.         Profiler.EndSample();
    22.  
    23.         // .NET 3.5 total GC:  48 bytes
    24.         // .NET 4.x total GC: 112 bytes
    25.  
    26.  
    27.  
    28.  
    29.         // .NET 3.5: 48 bytes
    30.         // .NET 4.x: 112 bytes
    31.         // It shouldn't generate any garbage, only allocate some memory
    32.         Profiler.BeginSample("First StringBuilder constructor");
    33.         First = new StringBuilder("Small");
    34.         Profiler.EndSample();
    35.  
    36.         // .NET 3.5: 90 bytes
    37.         // .NET 4.x: 0 bytes
    38.         Profiler.BeginSample("First StringBuilder small init");
    39.         First.Append(" Test");
    40.         Profiler.EndSample();
    41.  
    42.         // .NET 3.5: 0 bytes
    43.         // .NET 4.x: 112 bytes
    44.         Profiler.BeginSample("First StringBuilder small concatenation");
    45.         First.Append(" - Adding");
    46.         Profiler.EndSample();
    47.  
    48.         // .NET 3.5: 0 bytes
    49.         // .NET 4.x: 96 bytes   // Shouldn't be generating garbage
    50.         Profiler.BeginSample("First StringBuilder clear");
    51.         First.Clear();
    52.         Profiler.EndSample();
    53.  
    54.         // .NET 3.5: 0 bytes
    55.         // .NET 4.x: 0 bytes
    56.         Profiler.BeginSample("First StringBuilder 2nd small concatenation");
    57.         First.Append(" - Adding");
    58.         Profiler.EndSample();
    59.  
    60.         // .NET 3.5: 0 bytes
    61.         // .NET 4.x: 0 bytes
    62.         Profiler.BeginSample("First StringBuilder 2nd clear");
    63.         First.Clear();
    64.         Profiler.EndSample();
    65.  
    66.         // .NET 3.5 total GC: 138 bytes
    67.         // .NET 4.x total GC: 320 bytes
    68.  
    69.  
    70.  
    71.  
    72.         // .NET 3.5: 48 bytes
    73.         // .NET 4.x: 150 bytes
    74.         // Note that the initial size of the text put in the StringBuilder constructor changes something, which is not normal at all, there should be only allocation, no memory release at that point
    75.         Profiler.BeginSample("Second StringBuilder constructor");
    76.         Second = new StringBuilder("Large initial Stringbuilder content");
    77.         Profiler.EndSample();
    78.  
    79.         // .NET 3.5: 166 bytes
    80.         // .NET 4.x: 150 bytes
    81.         Profiler.BeginSample("Second StringBuilder small init");
    82.         Second.Append("Test");
    83.         Profiler.EndSample();
    84.  
    85.         // .NET 3.5: 306 bytes
    86.         // .NET 4.x: 220 bytes
    87.         Profiler.BeginSample("Second StringBuilder large concatenation");
    88.         Second.Append(" - Adding a lot of character to a StringBuilder shouldn't be a problem for the garbage collector");
    89.         Profiler.EndSample();
    90.  
    91.         // .NET 3.5: 0 bytes
    92.         // .NET 4.x: 312 bytes      // Not normal, it's a 'Clear()'
    93.         Profiler.BeginSample("Second StringBuilder clear");
    94.         Second.Clear();
    95.         Profiler.EndSample();
    96.  
    97.         // .NET 3.5: 0 bytes
    98.         // .NET 4.x: 0 bytes
    99.         Profiler.BeginSample("Second StringBuilder 2nd large concatenation");
    100.         Second.Append(" - Adding a lot of character to a StringBuilder shouldn't be a problem for the garbage collector");
    101.         Profiler.EndSample();
    102.  
    103.         // .NET 3.5: 0 bytes
    104.         // .NET 4.x: 0 bytes
    105.         Profiler.BeginSample("Second StringBuilder 2nd clear");
    106.         Second.Clear();
    107.         Profiler.EndSample();
    108.  
    109.         // .NET 3.5: 0 bytes
    110.         // .NET 4.x: 360 bytes
    111.         Profiler.BeginSample("Second StringBuilder very large concatenation");
    112.         Second.Append(" - Adding a lot of character to a StringBuilder shouldn't be a problem for the garbage collector - Adding a lot of character to a StringBuilder shouldn't be a problem for the garbage collector");
    113.         Profiler.EndSample();
    114.  
    115.         // .NET 3.5: 410 bytes      // That's very strange, no garbage with the previous concatenation, so why does this 'Clear()' generate garbage?
    116.         // .NET 4.x: 0.6 Kilo-bytes
    117.         // It's a 'Clear()', we shouldn't be generating any garbage
    118.         Profiler.BeginSample("Second StringBuilder 3nd clear");
    119.         Second.Clear();
    120.         Profiler.EndSample();
    121.  
    122.         // .NET 3.5 total GC:  930 bytes
    123.         // .NET 4.x total GC: 1806 bytes
    124.     }
    125.  
    126.     Int32 FrameCounter = 0;
    127.     public void Update()
    128.     {
    129.         if (FrameCounter > 5)
    130.         {
    131. #if UNITY_EDITOR
    132.             UnityEditor.EditorApplication.isPlaying = false;
    133. #else
    134.             Application.Quit();
    135. #endif
    136.         }
    137.  
    138.         FrameCounter++;
    139.     }
    140. }
    141.  
    142. #if NET_LEGACY
    143. public static class StringBuilderExt
    144. {
    145.     public static void Clear(this StringBuilder this_)
    146.     {
    147.         this_.Length = 0;
    148.     }
    149. }
    150. #endif
    151.  
     
    JoshPeterson likes this.
  6. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    As anticipated, Unity has classified this as being 'By design'.
    https://issuetracker.unity3d.com/is...re-garbage-in-net-4-dot-x-than-in-net-3-dot-5

    I think that it's a shame.
    When using StringBuilder, one is expecting to avoid GC, and unfortunately it only reduce GC compared to 'String', but it increase it compared to the 'StringBuilder' from .NET 3.5.

    Is there a way to use our own custom implementation of StringBuilder rather than the one in Unity?
     
  7. joncham

    joncham

    Unity Technologies

    Joined:
    Dec 1, 2011
    Posts:
    276
    You can easily copy in and modify, or write your own version of StringBuilder, and use that in your project.
     
  8. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    I'm afraid that won't work.
    There is no point in having a custom 'MyOwnStringBuilder' class if I cannot use these 'MyOwnStringBuilder' objects in Unity.
    For example, TextMeshPro can take a StringBuilder as an input in the 'SetText()' method, but it will not be able to take a 'MyOwnStringBuilder' text as an input, so I will have to create a normal 'StringBuilder', and then provide it to TMP.
    But by doing that, I will generate garbage, because the standard 'StringBuilder' generate garbage.

    In addition, 'StringBuilder' is sealed (and has no virtual methods anyway), so I cannot inherit it and modify the methods I need.

    For now, the only way I found to use a custom StringBuilder is to replace the original methods using assembly to insert a 'JMP' instruction at the very beginning of the original method in order to call my own custom method.
    While it will certainly work, I would prefer to avoid using such extreme solutions...

    Unfortunately, unless if we have a way to recompile some system dlls, I don't really see how to have a custom StringBuilder which can be used directly in Unity.
     
  9. joncham

    joncham

    Unity Technologies

    Joined:
    Dec 1, 2011
    Posts:
    276
    True, you cannot pass your own StringBuilder implementation to APIs expecting a System.Text.StringBuilder. For the general use case of building strings you can use your own implementation if you wish.

    However, the implementation of StringBuilder in the new scripting runtime comes from the .NET reference source. It does have different allocation behavior than the old one written within Mono. The heuristic for growing the character buffers is different, and consolidates multiple buffers on Clear. You can avoid this issue by allocation a larger initial buffer for the StringBuilder. For example, if I use initial size of 64 for `First` and 256 for `Second` in the test case, I get less allocation than on old .NET.

    upload_2019-2-17_22-22-33.png
     
  10. Gladyon

    Gladyon

    Joined:
    Sep 10, 2015
    Posts:
    389
    That's true, the garbage generated on 'Clear()' is only done once, at least as long as we do not exceed the capacity again.

    But it's still a shame that creating a StringBuilder (or clearing it in some situation) generate GC.
    And I haven't even looked at the insertion/removal of characters in the middle of the StringBuilder.

    I guess that a way to solve the problem would be to reuse the StringBuilders with a pool, after some time all the pooled StringBuilder would have a large capacity and wouldn't generate GC anymore.
    But putting them back on the pool would add a lot of complexity.