Search Unity

Should i use an Update Manager - the 50k Update() conundrum

Discussion in 'Scripting' started by csofranz, Nov 15, 2019.

  1. csofranz

    csofranz

    Joined:
    Apr 29, 2017
    Posts:
    1,556
    Roughly once a sixweek, an observant developer wonders what is more performant in Unity: having a single script controlling hundreds of objects, or hundreds of objects that each have their own single script? This question usually boils down to 'should I use an update manager?'

    The answers to the latter question isn't clear cut, with an incredibly good blog post ("10000 Update() calls") in late 2015 that makes important contributions and gave rise to great improvements in Unity; but it also – lamentably – said article gave rise some (common?) misconceptions and perhaps questionable practices.

    Now, please note that we have moved the goalpost in the first paragraph: we switched from 'what is faster' to 'should I manage updates'. This is because there is a definite answer to 'what is faster in Unity: one script that manages 100 objects or 100 individual scripts?' – and that answer is 'one script' (if done correctly).

    However, instead of 'should I use an update manager?', a better follow-up question is 'how much performance would I save', and the one right after that 'is it worth the hassle'? Too often, people jump from 'there's a faster way of doing this' to 'let's do it', without fully examining whether doing so is appropriate. [I feel similarly when it comes to getters and setters in C#, but leave that discussion for another day]

    Unfortunately, those follow-up questions aren't easily answered and the answers can change drastically over time. Worse, when they are answered, they tend to get misinterpreted or unintentionally misrepresented. Considering that I'm now about to embark on trying to answer these questions myself, I hope I don't screw this up too badly:)

    Here are the tree questions I'm trying to answer
    • Is using managed updates faster?
    • How much performance do I save when I do that?
    • Should I use managed updates?

    Some background:
    MonoBehaviour has a number of special ('magic') methods that Unity knows and invokes if they are present in your code, even if you don't declare them as 'public'. Of all existing methods, Update() is the most important one, and if it's present, it is invoked every Frame if the script is enabled and the parenting object is active. Unity has special code that optimizes invoking Update, but people have found that even though invoking Update() via Unity is fast, it's not as fast as it can be.

    So when every bit of performance counts, and since the optimizations accumulate the more scripts you have, people have devised a clever alternative to the 'magic' invocations by Unity: so-called managed Updates. Common wisdom is that you should use Update managers when you have many scripts, and you want to optimized performance. However, just like common sense, I suspect that "common wisdom ain't".

    Setup
    So let's prepare. I created three versions of Update designed to test:
    • "magicUpdate": a script where Update() is invoked by Unity via their 'magic' invocation. Data is stored locally in the script as an attribute
    • "managedUpdate": a script to be used with managed updates. It has no Update that would be found by Unity. The manager caches and calls a replacement ‚managedUpdate()‘ method while iterating the objects. Data is stored locally in the script as an attribute
    • "inlined": this script has no Update(); the manager fully replicates the 'payload code' from Update() and executes it without ever invoking another method. The iterated object's script is used to store data, and we access one attribute
    For each object, the Update's payload code is minimal and identical: it increments a float (stored in the iterated script) by 1.0f

    The scene is set up by a scene management object that also doubles as update manager (when required). The scene is initialized thusly:
    • create 50'000 new game objects on root level, and
    • attach a single component to each object. What component is added depends on what we want to test: 'magic', managed, inline.
    • if we are testing managed updates or inline, we also cache each object
    The manager object that creates the test objects also manages the updates when required. We control what we are going to test via public bools. We get the execution time of all scripts per frame from the Profiler, which will also run in the scene.

    In order to get some semblance of reliable results, we'll run each scene multiple times, note the average script processing time of these runs (after they even out), note them down, and average.

    But before we run, let's try to predict what we'll see, and argue why:
    • The inlined version should be fastest. No invocations will be impossible to beat versus 50'000 invocations. This establishes the ceiling: you can't get "faster" (or phrased better: you can't spend fewer cycles) to achieve what's done in The payload. In other words: Inline is the cost of 'doing business', it represents the performance required for processing the 'update payload', plus any other demands that may pollute our timing. This will be subtracted from the other two results so we arrive at the pure invocation cost, with the pollution and payload processing cost subtracted out.
    • The managed updates should be faster than the magic update() version. I'm guessing that it'll be faster by a factor of two to three (meaning that magic invocations take twice as long as managed) because Unity iterates multiple lists, and performs pre-processing, which will take time.

    IMPORTANT CAVEAT
    All my tests were done in Mono, I did not use IL2CPP, so we are looking at the results of Mono's Just-In-Time compiler. A hallmark of JIT compilers is that the code they generate isn't as heavily optimized as pre-compiled code from a static compiler such as that used for IL2CPP. Also, since we use JIT, the first few frames will see processor spikes due to the fact that all scripts (not just the test scripts but everything else) need to be compiled. Therefore, we'll let the system 'settle' first, and disregard processor usage during the first 10 seconds.

    Tests were conducted in late 2019 on an i7@3.2GHZ, 6 Cores, 12 MB L3, hosted by Windows 10, Unity 1018.4.x

    Since the editor and profiler are running, I expect the results to be slightly polluted by the editor/profiling overhead. However, since this overhead is also included in the inlined time, it will be subtracted out as well. There may be some residual pollution, so the actual time you can save by managing updates or inlining will probably be slightly less than what we'll find.

    Enough already, run the test!

    50'000 objects/scripts, all times in ms
    Magic | Managed | Inlined
    5.0 | 1.1 | 0.8

    OK, wow. This was not what I expected. Cleaned up (by subtracting out the payload and background noise that we timed with the 'Inlined' method) this means that invocation times for 50'000 objects are

    Pure Invocation Time in ms, 50'000 invocations
    Magic | Managed | Delta | Factor
    4.2 | 0.3 | 3.9 | 14

    Per-invocation, all time in ms
    Magic | Managed | Delta | Factor
    0.000084 | 0.000006 | 0.000078 | 14

    In other words, a magic update invocation takes 14 (four-frigging-teen) times as long as a cached direct invoke from the manager.


    Ouch.

    What does this mean?
    Before we go any further, allow me to emphatically state the following:

    Even though the invocation itself is up to 14 times faster if you are using managed updates, THAT DOES NOT, NOT, NOT MEAN THAT USING A MANAGER WILL SPEED UP YOUR ENTIRE UPDATE BY A FACTOR OF 14. This seems to be a common misconception, and some people have implemented update managers in the hope of significantly reducing update time. That is impossible, because we can't reduce the time taken to execute the payload. All we can speed up is the time to start Update(), not processing update itself. The fastest way to start Update() takes 0.000006 ms, so the upper ceiling of what you can save using managed updates is 0.000078ms (for systems set up like the one used for tests).

    Why was my initial estimate so far off?
    There are many possible reasons, not least of which being that I'm an idiot. So beside that trivial answer, there's a lot that our manager code doesn't do that Unity does: observing if the parenting object is enabled or if the script is enabled, and not invoking managedUpdate when the parenting object was destroyed. It also allows scripts to be re-ordered in execution that we don't provide for. And I over-estimated the time it would take to perform a simple invoke versus processing multiple lists/arrays, a rather significant blunder.

    So, more tests to save some face. Let's create a more 'real-word' update manager that also checks if the object exists, is active, and if the script exists and is enabled. I added four if-clauses to the manager before invoking:

    Code (CSharp):
    1.   if (theScript == null) continue; // does the script exist?
    2.   if (theScript.gameObject == null) continue; // does the parenting object exist? Perhaps not required.
    3.   if (!theScript.gameObject.activeInHierarchy) continue; // is object active?
    4.   if (!theScript.enabled) continue; // is script enabled
    This should make the update manager safe against invoking update on deleted, inactive or disabled objects/scripts. Running the test I got the following results (after subtracting inlined)

    Pure Invocation time in ms, 50'000 invocations
    Magic | Managed | Delta | Factor
    4.2 | 14.3 | -10.1 | 0.29

    Adding four consecutive if clauses has a devastating impact on the update manager: magic updates are now more than three times as fast, and using the manager incurs a 10.1ms penalty – using this update manager makes matters worse!


    Da Foo???


    More tests revealed how much each if clause contributed:

    Processing overhead in ms, 50'000 invocations
    Clause Impact
    Script == null 3 ms
    Object == null 3 ms
    Object active 4 ms
    Script enabled 4 ms

    Now what does THIS mean?
    It's actually really difficult to beat Unity at their own game. If you include just one of the above four sanity checks in your own update manager, you lose *all* performance advantages. Unity's magic updates, on the other hand, include all safety checks, while yours only has one. In order to achieve parity with unity, you'd have to first write your own 'managedMonoBehaviour' class that overrides Destroy(), onEnable(), onDisable() to interface with your update manager, and then make all scripts that are managed descendants from that class. That's a lot of effort for very little gain. And at the end of the day, you've still lost integration with Unity's script prioritization capabilities.

    But for me this drove home two important points:
    • it's really easy to jack up performance cost with a single costly statement in your inner loop. In other words, before you engage in big-scale changes like re-rigging one of the central aspects of Unity (the Update loop), take a good look at the profiler to see if you can identify better optimization targets.
    • you are really saving only very little performance with an update manager. You are saving about the same performance that is required for a single if-clause.
    Since some sanity checking is advisable, real-world implementations of update managers will come with added complexity, and won't be able to realize the full 3.9 ms potential. Real-world implementations (such as this one at Github https://github.com/Kolyasisan/Core-Update-Manager ) seem to indicate likewise. The authors managed to save 0.5 ms with their update manager – a modest amount to be sure, but this on a resource-constrained mobile device, where every millisecond saved is a major victory. Since they don't indicate how many objects were managed, I have no idea what that would translate to on my system, but probably less, because desktops are much faster and have fewer constraints.


    Is using an update manager faster than magic updates?
    Yes.
    … but only marginally, and only if done right,

    How much performance is saved when I switch to managed updates?
    Less than most people assume. As I noted before, there is a hard ceiling to what can be saved: the overhead Unity introduces before it actually invokes the magic Update(). There is not a single cycle saved while executing the actual payload. If we assume that you can get away with an optimal invocation code (no checks as to if the object is alive, active and the script enabled), you can save 0.000078ms per invocation - usually a little less; based on a system comparable to the one I used for this test. And if you need any operational safety, it's very easy to screw up your manager and end up with a system that performs worse than magic invokes.

    Should I use managed updates?
    This is where it gets less clear, and I tend to say 'no'. The amount of processing time saved is small and limited – but it does accumulate. At 50 fps, you have 20ms of processing time. Take off 10ms for rendering and physics, and you still have 10ms left for script processing. Yes, at 50'000 scripts you definitely would want to save those 3.9 ms, and should therefore consider using a manager. I said "Consider". Because it is not a given that your update manager delivers on that slim potential. Other (newer) technologies promise to deliver much greater performance gains, so if you are exploring architectural changes, don't limit yourself to update managers alone.

    At 10'000 - no. However, if you are developing for mobile devices (that have much less performance headroom and tiny caches), using an update manager may already become interesting at 10'000 scripts. Again, be careful with your choice, and also consider alternatives.

    And remember that there are important downsides to using an Update manager:
    If you switch to managed Updates, you'll likely have to change your code, making it non-standard and more difficult to maintain. If you use any kind of script prioritization, that is likely not work with your manager. If you did not anticipate using an update manager at the design stage, you are looking at some very uncomfortable and bug-prone refactoring.

    What did I learn?
    To me, the main takeaway is this: performance saved by managed updates have a hard, and surprisingly low, ceiling. That ceiling may lower in the future if Unity further improve their magic invocations. Any performance savings gained through an update manager can be easier achieved by optimizing the payload code itself: getting rid of expensive calls (like GetComponent<>(), Camera.main, Find(), AddComponent, Log, or – astonishingly – comparing an object to null) often saves more time than you ever can hope to save using an update manager. So you should only turn to update managers once you have exhausted all other means. But using update managers is so fundamental that the decision should be made early – and this may amount to premature optimization.

    Since the performance increases are so modest, you can get much better results when you fix hotspots that Unity's profiler identifies. And for completeness sake: remember that you can switch to IL2CPP for a modest performance boost before you resort to architectural changes.

    Personally, looking at the results of these tests, I'd only go for an update manager if I know at design stage that I'll have more than 25k objects with scripts in the scene that require update and I know that I'll be pressed for performance (well, if you have that many objects in your scene, that's probably a given). At that point, though, I'd also be looking into ECS and other alternatives to increase performance.

    In all other cases, I'd stay away. To me Update Managers have too many strings attached and deliver too little return on investment to be anything more than last resort. There are edge cases where you want to spread updating numerous objects over multiple frames. This may warrant using an update manager. Using an update manager this way is not for the reason that we looked at it: performance.

    Closing Notes: other OSes (macOS)
    I ran the same tests on a Mac equipped with the same processor (literally, the Windows tests were done on a Mac booted into Windows) with the following results:

    Pure Invocation time in ms, 50'000 invocations
    Magic | Managed | Delta | Factor
    13.2 | 0.5 | 12.7 | 26.4

    Managed safe
    13.3

    While at first blush the number look radically different, they are not that much apart. One reason for the disparity is the relatively high Inline processing time of 2.8 ms (versus 0.8 on Windows). Generally Unity seems to have a larger performance overhead on macOS (please – I'm not advocating either OS, we are only talking about update managers here) when Editor and Profiler are running, and that overhead factor seems to scale across the board. This does not mean that code executes three times faster on Windows. Rather, the 'Managed safe' disparity seems to indicate that in addition to the way processor time is shared between Player, Editor and Profiler, the implementation for magic invokes is different on macOS. This also indicates that update managers on macOS have a somewhat greater potential versus Windows.

    It shows that the OS itself does have a discernible impact, but it doesn't change the game: update managers have a relatively low ceiling, and should only be used after very careful consideration.

    tl; dr:
    Update Manager? When in doubt: no.

    Below is the entire code - if you have the time and inclination, I'd love to see your results.
    To try this yourself,
    1. Create a new scene
    2. Copy the script to a new csharp script and save
    3. Create a new empty object
    4. Attach the new script
    5. Run

    Thanks for your interest, I'll step off that soapbox now.

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4.  
    5. //
    6. // Update test for Unity, Copyright (c) 2019 by Christian Franz
    7. //
    8. // Simply attach this script to an empty game object in an empty scene,
    9. // and you are all set.
    10. //
    11.  
    12.  
    13. public class noUpdate : MonoBehaviour {
    14.     public float angleIncrease; // just a float
    15.  
    16.     public void Start() {
    17.         angleIncrease = Random.Range(0f, 1.0f); // make attribute start at random
    18.     }
    19.  
    20.     public void managedUpdate() {
    21.         // payload code
    22.         angleIncrease += 1f; // simply add constant 1;
    23.     }
    24.  
    25. }
    26.  
    27.  
    28. public class withUpdate : noUpdate {
    29.     public void Update() {
    30.         // payload code
    31.         angleIncrease += 1f; // simply add constant 1;
    32.     }
    33.  
    34. }
    35.  
    36. public class UpdateTest : MonoBehaviour {
    37.     public enum testType {magic, managed, magedSafe, inlined }
    38.     public testType testWhat = testType.magic;
    39.     public int numObjects = 50000; // number of objects to spawn
    40.     private List<noUpdate> controlledObjects = new List<noUpdate>();
    41.  
    42.     public void spawnObjects(int num) {
    43.         while (num > 0) {
    44.             GameObject go = new GameObject(num.ToString());
    45.  
    46.             if (testWhat != testType.magic) {
    47.                 // attach a script without Update()
    48.                 noUpdate theScript = go.AddComponent<noUpdate>();
    49.                 controlledObjects.Add(theScript);
    50.             } else {
    51.                 // we use the magic update, add a component that implements Update()
    52.                 go.AddComponent<withUpdate>();
    53.             }
    54.             num -= 1;
    55.         }
    56.     }
    57.  
    58.     public void Start() {
    59.         spawnObjects(numObjects);
    60.     }
    61.  
    62.     public void Update() {
    63.  
    64.         if (testWhat == testType.magic) return; // we are using each object's own update
    65.  
    66.         // if we get here, we need to iterate through all objects and update them
    67.         // note: in c++, an array is faster than a List<>. This is relevant if you
    68.         // use IL2CPP instead of Mono
    69.  
    70.         // we only switch once, and implement the innermost loop
    71.         // for each case separately
    72.         switch (testWhat) {
    73.  
    74.             case testType.managed:
    75.                 foreach (noUpdate nu in controlledObjects)  {
    76.                     nu.managedUpdate();
    77.                 }
    78.                 break;
    79.  
    80.             case testType.magedSafe:
    81.                 foreach (noUpdate nu in controlledObjects) {
    82.                     if (nu == null) continue;// -- adds 3ms for 50'000
    83.                     if (nu.gameObject == null) continue; // adds 3ms for 50'000
    84.                     if (!nu.gameObject.activeInHierarchy) continue; // adds 4 ms for 50'000
    85.                     if (!nu.enabled) continue; // adds 3.8ms for 50'000
    86.                     nu.managedUpdate();
    87.                 }
    88.                 break;
    89.  
    90.             case testType.inlined:
    91.                 foreach (noUpdate nu in controlledObjects) {
    92.                     // payload code, inlined. no invocation
    93.                     nu.angleIncrease += 1f; // simply add constant 1;
    94.                 }
    95.                 break;
    96.  
    97.             default:
    98.                 break;
    99.         }
    100.     }
    101.  
    102. }
    103.  
     
    Last edited: Nov 15, 2019
  2. Dameon_

    Dameon_

    Joined:
    Apr 11, 2014
    Posts:
    542
    You can remove the cost of "sanity checks" by making an Update manager that's subscription based, and simply managing subscription/unsubscription in methods like OnEnable/OnDisable/OnDestroy. Then, you're not doing any kind of sanity checks, the flow of subscribe/unsubscribe is natural, and you gain a lot of flexibility. For example, what if you want a script on a disabled GameObject to keep running per-frame, for reasons like detecting input to re-activate that GameObject? Once you have a subscription-based system, that is a tiny bit of overhead, but far less consistent overhead than the magic Update method, and you gain immense amounts of flexibility (like not having to have a MonoBehaviour to tie into the Update loop).

    Here's a script I wrote a while ago that's a subscription-based answer to the whole Update Manager quandary. It uses Interfaces, and Interface method calls do have an extra processing cost to them, but it's still faster than relying on the magic Update method. I tried events, but unfortunately, doing the same thing in an event-based manner winds up generating garbage, so Interfaces are the best answer that I found.
     
  3. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    38,697
    Using these is awesome for subs and unsubs.

    I will add however that it is rarely necessary to unsub in OnDestroy and you're more likely going to have issues with that approach related to app shutdown. Just do it in OnEnable and OnDisable unless there is REALLY a reason you need it in OnDestroy

    ALSO, here's the lifecycle of a Unity object. You absolutely NEED to understand this to do anything significant with Unity engineering, and the details and nuances of behavior will be fully explained by this chart:

    https://docs.unity3d.com/Manual/ExecutionOrder.html
     
    Dameon_ likes this.
  4. neoshaman

    neoshaman

    Joined:
    Feb 11, 2011
    Posts:
    6,493
    He is basically redoing DOTS ...
     
    Kurt-Dekker likes this.