Unreal Engine, and the hidden pitfalls of Blueprints
Hello!
Blueprints are useful, quick, and easy to use once you get used to them.
Event graphs are incredibly helpful to creating certain gameplay, cross-function references are particularly useful in certain scenarios.
However useful and quick Blueprints are, there are quite a few hidden pitfalls that can unintentionally cause avoidable massive performance drops. Some pitfalls that Blueprints offer can actually cause bugs that aren’t very straightforward to find unless you know of some the inner workings of Blueprints.
Explaining all pitfalls of Blueprints in one blog post would be an unsurmountable feat, thus I have decided to make this a series that will look at specific pitfalls in Blueprints and document each one in detail.
Pure Performance Pitfalls with Loops
When it comes to performance, Blueprints are generally slower than c++, but Blueprints can still be useful, and complex functions can still be created if you know what to look out for.
First and foremost, one of the major causes of performance loss in blueprints: Pure functions (Those without exec pins such as “GetRandomPointInNavigableRadius “).
They’re handy, they’re neat, and don’t require you to hook up an execution pin! But; alas these pure functions come with a lot of pitfalls if used incorrectly. Take the following example:
This looks harmless, but unfortunately, this will call the function “GetRandomPointInNavigableRadius” twice. That is because pure functions run once per connection to a node. It runs once to check the Return Value and it runs once to Print String.
It seems quite counterintuitive, but it will make more sense if you take a look at the following example:
Initially, we set Origin to -100,-100,-100, we get a random point in radius, we check if this was successful. It was? Great let’s print it, but before we do so, let’s change the Origin to something else. Now, if we Print String, it will run the pure function again
Blueprints don’t know in the first example that you didn’t change the origin between the Branch and Print String, neither does it know you changed the Origin in the second example to something else before running the Print String.
Because Blueprints don’t know and don’t check, they always run once per connection, regardless if changes were made that would have no consequences to the return values.
Let’s put this theory to the test and proof it.
To proof this theory, do the following: Create a pure function that just returns the number 20, but add a print string to the function, so that every time it runs, it will print your string.
Now, create a function that has a simple ForLoop and plug the PureProof function into “LastIndex”. Then print the index you have.
If you run this function, you will get 43 prints in the log.
21 times the print string inside the PureProof
21 times the print string after the loop
1 final time it runs the PureProof after the last index was run to decide not to run anymore because it reached the final index.
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 0
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 1
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 2
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 3
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 4
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 5
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 6
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 7
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 8
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 9
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 10
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 11
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 12
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 13
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 14
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 15
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 16
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 17
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 18
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 19
LogBlueprintUserMessages: [Untitled_C_4] I was run!
LogBlueprintUserMessages: [Untitled_C_4] 20
LogBlueprintUserMessages: [Untitled_C_4] I was run!
Now, this is quite concerning. But for the purposes of this example, this is not a worrisome scenario, the pure function just returns a value, it doesn’t do anything expensive.
Now, imagine you do the following in PureProof.
This function, would still be called 21 + 1 times, however; the loop inside of PureProof would also run 21 + 1 times, which can be a major performance issue if you are doing any sort of expensive checks in here, or are doing it to return a filtered result of an array.
The reason it does this is because ForLoop is not a function, but a macro, a macro that checks the length of last index every iteration, and each iteration it will run PureProof to get the last index value.
Now let’s take a look at a much more costly pure function.
Now, this initially looks quite harmless, it goes over every character of the array and prints each character. However, this is actually a cause for concern.
As we know now, pure functions call once per connection, and in the case of a ForEachLoop it will call twice per iteration.
Inside the ForEachLoop there are two main nodes that cause this behavior. “Length” and “Get”.
Each iteration, the macro checks if the loop counter inside of the loop is smaller than Length of the array. So every iteration it has to get the length of the array. If the check passed, we do a Get, this gets the array and gets the object from the array at the index.
Take a look at the following example:
Running this code ends up running the PureArrayProof function 13 times.
12 times for all iterations, 2 per iteration. One additional time to check if we reached the length of the array (and we did).
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 0
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 1
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 2
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 3
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 4
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] I have run!
LogBlueprintUserMessages: [Untitled_C_8] 5
LogBlueprintUserMessages: [Untitled_C_8] I have run!
I hope that at this point you can see how loops and pure functions can be a devestating effect on your performance. Let’s take this to the extreme and re-create a scenario that I have seen in a project once, which was cause for quite a heavy performance impact.
Then running the following:
This causes the function “GetFilteredArray” to run an incredibly high amount of times. The GetFilteredArray function loops over 0-40 range, let’s say it loop 40 times for the sake of simplicity when calculating the amount of times this runs.
Let’s say it filters 50/50, so we end up with 20 filtered results inside “PureArrayProof“. It again filters 50/50, ending up ith 10 results when we run “PureArrayProof” in the event graph.
Knowing, that a ForEachLoop runs twice per execution + 1, we know that the function “PureArrayProof” will run 21 times before being done with work.
Knowing that PureArrayProof runs 21 times, we know that “GetFilteredArray” will run 41 times per iteration of PureArrayProof.
This ends up to running GetFilteredArray 861 times (21 x 41).
Knowing that GetFilteredArray runs 861 times, inside GetFilteredArray we run ForLoop 40 loops, which does ExpensiveCheckFunction.
Knowing it runs 40 loops, it will execute ExpensiveCheckFunction 41 times per time that GetFilteredArray is run.
This results in running ExpensiveCheckFunction a whopping 35,301 (41 x 861 ) times for the “simple” print string to run 10 times in the event graph.
Now how do we solve this?
Simply cache the result of “GetFilteredArray” and “PureArrayProof ” before running the ForEachLoop inside the event graph. It seems like such a simple fix for such a complex problem, but this really is all you have to do.
Alternatively, don’t make the function pure and simply let the node have exec pins (I advise doing this for anything that is remotely expensive to avoid accidentally running into this issue).