Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Question Does declaring a class type variable in local scope go to stack or heap for GC purposes?

Discussion in 'Scripting' started by luniac, Feb 3, 2023.

  1. luniac

    luniac

    Joined:
    Jan 12, 2011
    Posts:
    598
    I have a C# script called PathTrigger, if i have the following code below in a different script:

    Code (CSharp):
    1.      private void OnTriggerEnter2D(Collider2D other)
    2.         {
    3.  
    4.             if (other.GetComponent<PathTrigger>())
    5.             {
    6.                 PathTrigger pathTrigger = other.GetComponent<PathTrigger>();
    7.                ...
    8.                ...
    9.                ...
    10.             }
    11.  
    12.         }
    Will the local variable pathTrigger create garbage after every OnTriggerEnter2D?
    I was thinking of caching it locally to avoid multiple GetComponent calls.

    I know value types are on the heap, but what about a variable that is a class?
     
  2. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,856
    I'm pretty sure it's just value type == heap, reference type == stack (in basic terms).

    Though honestly there's two better ways to code your example:
    Code (CSharp):
    1. var pathTrigger = other.GetComponent<PathTrigger>();
    2.  
    3. if (pathTrigger != null)
    4. {
    5. }
    Or the recommended pattern these days:
    Code (CSharp):
    1. if (other.TryGetComponent(out PathTrigger pathTrigger))
    2. {
    3.  
    4. }
     
    Bunny83 likes this.
  3. luniac

    luniac

    Joined:
    Jan 12, 2011
    Posts:
    598
    well var pathTrigger would still allocate to the stack and therefore cause GC allocation every OnTriggerEnter right?

    I'd rather take the performance hit of calling GetComponent or TryGetComponent a few more times versus allocating memory for GC collection.
     
  4. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,856
    Using
    if (other.GetComponent<PathTrigger>())
    is probably still going to allocate because you're still testing the return on GetComponent<T>. Using it twice like you are is probably two allocations as opposed to one.

    Also it goes without saying don't optimise your code around theoretical performance issues. Just make your code readable first and foremost, and if you have a performance issue, use the profiler rather than guessing.
     
  5. luniac

    luniac

    Joined:
    Jan 12, 2011
    Posts:
    598
    but i hate the profiler :( lol

    Wait since when did GetComponent allocate?
    hmm i guess it does make sense if you're gonna modify the gotten component, so it needs to be referenced. damn.

    EDIT:
    did some research, so if GetComponent returns null then no allocation happens(except in editor which TryGetComponent fixes), but if there is an actual component returned then allocation does occur, so i guess i should cache it locally if im gonna otherwise call GetComponent several times.
     
  6. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,856
    It's your only way to pinpoint the actual source of performance issue. Otherwise you're just aiming blind.

    I'll repeat: Don't speculate. You have to be doing things in the magnitude of tens of thousands of times per second to be even close to creating a bottleneck.

    Most of the time it will never be an issue.
     
  7. luniac

    luniac

    Joined:
    Jan 12, 2011
    Posts:
    598
    yea yea i know, just feeling nitpicky atm.
    But id always rather take more memory than risk micro stutters, cause eventually GC will accumulate... unless i do a GC call every number of frames which i used to do and it seemed to work well lol.
     
  8. MartinTilo

    MartinTilo

    Unity Technologies

    Joined:
    Aug 16, 2017
    Posts:
    2,160
    Allocating a new instance of a class with
    new
    within your local scope will place an object on the heap. (A struct instances would end up on the stack)

    GetComponten or TryGetComponent only allocates a managed wrapper to the native component if it doesn't yet exist, e.g. when you're trying to get a Collider that hasn't been referenced from a script yet.

    GetComponent on a MonoBehaviour will never allocate, as the Managed Shell for it will always exist as soon as the native component exists, because it is a MonoBehaviour that has managed logic attached to it and needs that shell to work.

    That said, GetComponent on a component that doesn't exist will allocate an Error object in the Editor (and only there) to then log an Error that knows what type of object it was supposed to be when trying to access it. The CPU Usage Profiler is aware of that edge case and will hide the allocation from the hierarchy, but it will show up on the Memory module and it can trigger GC.Collect. optimizing that out is not necessary for good Player performance though and would kind of be an Anti-Pattern.
     
    Last edited: Feb 3, 2023
  9. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,509
    No, for two reasons.

    First of all, items on the stack are not garbage collected, they're deleted when the stack frame collapses.

    Second, the reference itself is a value stored on the stack (in this case). When people talk about "heap allocation" the issue (usually) isn't the reference, it's the object which gets created, which in C# always happens on the heap. The reference to the object is a value.

    I recommend reading up on a couple of things:
    1. Value vs. reference types.
    2. Stack frames, and how they relate to function calls.

    One thing to note is that while value types can be allocated on the stack (where GC does not occur) they can also be allocated on the heap in various circumstances.

    In the Editor, if there is no matching component then an object gets created with some contextual data for error reporting. This doesn't happen in builds, and even in the Editor it doesn't happen if there is a matching component.

    Also, there is now TryGetComponent, which also has the benefit of allowing you to avoid doing null checks (which are pretty expensive in Unity's environment).
     
    luniac and Bunny83 like this.
  10. MartinTilo

    MartinTilo

    Unity Technologies

    Joined:
    Aug 16, 2017
    Posts:
    2,160
    Just to clarify, Nullchecks on objects of types inheriting from UnityEngine.Object, are a bit more expensive, because if the managed shell isn't gone, they check if the native object behind it is gone and if so return false.

    Plain old C# classes of other types don't have that overhead.
     
    angrypenguin and Bunny83 like this.
  11. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,380
    It's not the variable that allocates on the heap.

    It's the object that may allocate on the heap. And that allocation is only of consideration to GC once it no longer is needed and can be cleaned up.

    I want to mention this on top of the very good posts from MartinTilo for 2 reasons.

    1) this is regardless of the GetComponent method. May you have any method/function that returns a reference to a class. If that method is returning an existing object, nothing new is getting allocated. Like MartinTilo said... it's when you say "new" that the allocation on heap occurs. Consider if you had a list of objects, accessing that list does not allocate new objects.

    2) GC comes into play when nothing references your object. This is how GC works, it is counting how many places point at the object in question.

    Say this:

    Code (csharp):
    1. void DoStuff()
    2. {
    3.     var lst = new List<object>();
    4.     //do stuff with the lst
    5.     return;
    6. }
    In this method we created a list, and then when the method exits, we're done with it. We didn't store the list anywhere (a class level field?) so therefore nothing references it anymore. This means the list, a class, is now eligible for garbage collection.

    But lets say we have this scenario:

    Code (csharp):
    1. static List<object> lst = new List<object>();
    2.  
    3. void DoStuff()
    4. {
    5.     //do stuff with lst
    6.     lst.Clear(); //empty the list so it can be reused
    7. }
    In this example a single list is created and 'DoStuff' reuses that list over and over clearing it on complete. Ignoring the internal array that may or may not get created when growing the list... this list is allocated only once and never eligible for GC (unless you set lst = null, or to a newer instance of List<object>). Sure you've allocated an object on the heap, but it'll never GC either.

    And mind you... this isn't necessarily a good thing either. This object now permanently takes up memory until the program closes. If you went and engineered a way to NEVER dereference a single object ever, regardless of how many new objects you create, you've effectively just created yourself a memory leak.

    And to be clear... it's technically not the lack of references that causes the GC either. It's just what makes the object eligible for GC. What causes GC is when your memory manager determines its time to sweep the heap to clean things up. This can happen for reasons like you've gone and attempted to create a new instance of an object and there wasn't any easily accessible heap memory available. So the memory manager decides to cleanup the heap to find some space. It wants to try to reuse some memory rather than ask the OS for more memory. Because technically there is another layer to the memory management in that a program is only given so much memory from the system, and if it needs to grow beyond that, it needs to ask the OS for it. This is one of the primary jobs of an OS, sharing memory between multiple processes.

    ...

    While I'm here I want to also want to go back to something spiney said:
    Negating the fact they have this backwards (value type = stack, reference type = heap).

    Even that is technically not a correct statement.

    While creating a 'new' reference type does generally always allocate on the heap.

    Creating a new value type doesn't necessarily always allocate on the stack. A value type will allocate where it's been allocated.

    If the value type is in the scope of a method/function, and since method/functions running memory is the stack, then the value type will be on the stack. Because that's what the stack is. When a function is called a "stack frame" is allocated with a small bit of memory for everything necessary in scope to that function. Any value type variables, as well as the pointer/reference info for any reference type variables (not the entire object, just a handle to the object on the heap). There's no significant cost to the cleaning up of these values when the function ends because that's built into the cost of the function itself. It's an unavoidable cost fundamental to the design of how stack based functions work so therefore it's not really thought about in terms of expense the same way GC is within managed programming languages (sure arguments could be made about how to optimize this stuff... but I mean generally it's why you don't hear about people discussing the expense of value types on the stack).

    Thing is... value types can exist elsewhere as well.

    For example... when we say a reference type is allocated on the heap. What does that actually mean?

    It doesn't mean the class itself is allocated there. That's in the program memory. There's no reason to duplicate the logic of a function (yes generics complicates this conversation, but a generic is basically just an overload of an existing function, and once a generic of some type T is created, that specific implementation is not duplicated).

    Rather instead it's the fields of the class. The class level variables.

    Say you have the class:
    Code (csharp):
    1. class Blargh
    2. {
    3.     int ValueA;
    4.     float ValueB;
    5.     string ValueC;
    6. }
    What is going to be allocated on the heap when I say:
    new Blargh();

    Is roughly 12 bytes (plus some other crap needed for memory management, some potential padding, and the word size may change based on platform).

    4 bytes for an int
    4 bytes for a float
    and roughly 4 bytes to reference a string object which is allocated elsewhere (either in the string pool or elsewhere on the heap since string is also a ref type... note that on creation this string is null so really just the 4 or so bytes that can act as the reference pointer exists)

    But int and float are both value types! Yet they're on the heap! Because they are what make the object.

    When our instance of Blargh is GC'd... what is being reclaimed is the 12 or so bytes that these 3 fields were taking up.

    This is also what "boxing" is all about. If you say:
    object value = 5;

    You've just created a shell of an object that consists of a single int field on the heap. This int is on the heap.

    Point is... it's weird IMO to say "value types are allocated on the stack". It's more accurate to say "reference types are never allocated on the stack". And value types are allocated in the memory space they're needed.
     
    Last edited: Feb 3, 2023
    MartinTilo and Bunny83 like this.
  12. SF_FrankvHoof

    SF_FrankvHoof

    Joined:
    Apr 1, 2022
    Posts:
    780
    TIL, some components might not be allocated (by default) on the Managed side, even if they 'exist' in the scene.

    Question: Would/Could this managed wrapper ever get deallocated during the lifetime of the component?
     
  13. Neto_Kokku

    Neto_Kokku

    Joined:
    Feb 15, 2018
    Posts:
    1,751
    Since it's stored in a cache somewhere to be returned in the next GetComponent/TryGetComponent, I think it sticks around as long as the native object exists.

    IMO this is double-edged optimization. It saves a little bit of memory if your scripts only interact with a fraction of the components in a scene, but it makes the first time you do access those components a tiny bit more expensive.

    This is one of the many things you need to pay attention to when trying to implement seamless additive scene loading: having a ton of scripts doing GetComponent calls in their Awake/OnEnable/Start can easily cause a noticeable hitch during loading.
     
    MartinTilo likes this.
  14. Bunny83

    Bunny83

    Joined:
    Oct 18, 2010
    Posts:
    3,525
    Right. Even we don't have a definitive answer to this, any test you may run would show this behaviour. You can call
    System.GC.Collect
    manually to force the GC to run. Your script can implement a finalizer so you know when it got processed by the GC. Of course such things can't be tested for built-in components.

    Note that there are special cases where the managed part of built-in components may not be restored and that may be a domain / hot reload in the editor. Since everything is essentially recreated on the managed side, if there was no reference to a certain built-in component it's possible that the managed wrapper is not recreated right away but at the first access.

    For built-in components it would be difficult to test if the managed part is GCed even if the native part still exists. One possible approach could be to use weak references. WeakReferences always are a bit wonky and I wouldn't trust them too much. However we can observe weak references to say a BoxCollider becomes dead once the GC ran after the collider was destroyed. However when keeping the BoxCollider and only holding onto that weak reference, calling GC.Collect does not invalidate the weak reference. So as long as the native object is alive, the managed wrapper also seems to stay.
     
  15. MartinTilo

    MartinTilo

    Unity Technologies

    Joined:
    Aug 16, 2017
    Posts:
    2,160
    To confirm and clarify: @Neto_Kokku and @Bunny83 are right:
    The managed shell is held onto by the native object via a GCHandle and the only thing that will ever unload it while the native object is still around and holding onto that GCHandle is a Domain Reload.

    I don't recommend using Finalizers unless absolutely necessary, as they delay freeing up the memory they held to an extra step after GC.Collect, thereby possibly contributing to fragmentation as the memory isn't free after GC.Collect and if there wasn't enough space reclaimed by it, new memory will be requested from the OS before the finalizer finished and cleared up the space for their objects, which could have been used instead of the new space.

    Also, adding a Finalizer to the class adds an overhead of 48B to every instance of that class, via an additional allocation made by the Scripting Virtual Machine, right after the object on the managed heap. (Just learned about that last bit today and so not even the Memory Profiler knows about these extra allocations, meaning they currently appear as unused but reserved managed memory)
     
    angrypenguin and SF_FrankvHoof like this.
  16. brian1gramm1

    brian1gramm1

    Joined:
    Sep 15, 2017
    Posts:
    55
    Great words of wisdom!
     
  17. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,509
    I could be wrong, as it's implementation dependent, but I don't believe that this is the case.

    It's true that the GC will only clean stuff up once it's determined that it's no longer needed, but it has to spend effort determining which objects are and are not eligible for being GC'd, and my understanding is that this is where the GC spends most of its time. So the GC's workload is determined by the number of all managed objects, not just those which are no longer needed.
     
    Neto_Kokku and MartinTilo like this.
  18. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,509
    Out of interest, just how much of an overhead is the latter? I'm one of those crazy people who occasionally uses null checks for flow control, though not enough for this to have shown up as an issue so far.
     
  19. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,380
    Technically yes... but that's like saying "memory is dependent on how much memory is used". Of course the more memory used, the more memory that has to be monitored.

    The point of my statement is that an object isn't garbage until its eligible to be garbage. The "consideration" I'm referring to is if GC has considered it garbage.
     
  20. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,509
    To us it's an "of course". To people who are still learning about it, perhaps not. Intuitively, it would make sense that the time taken to free memory would depend on the amount of memory being freed, but that isn't the case.
     
  21. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,380
    Welp, can't always be perfectly clear...
     
  22. angrypenguin

    angrypenguin

    Joined:
    Dec 29, 2011
    Posts:
    15,509