Unreal Engine, and the hidden pitfalls of Blueprints

Sep 14, 2021

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:

A function that checks if the result of GetRandomPointInNavigableRadius is valid and then prints the result. This prints a random point around -100,-100,-100

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:

A function that checks if the result of GetRandomPointInNavigableRadius is valid, updates Origin, and then prints the result of the pure function. This prints a random point around 200, 200, 200

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.

A Const Pure function that prints “I was run!” and returns the value 20

Now, create a function that has a simple ForLoop and plug the PureProof function into “LastIndex”. Then print the index you have.

Am event that runs ForLoop from 0 to the return value of PureProof.

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.

A pure const function that runs a loop of 0-20 range and adds +1 on Final Value before returning the ReturnValue.

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.

The inside of the macro ForLoop

Now let’s take a look at a much more costly pure function.

A loop that gets all characters of a string and prints each character

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”.

The inside of the For Each Loop macro

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:

A pure const function that prints “I have run!” and returns an array of 0-5 range each with their index as value.
An event that calls ForEachLoop with PureArrayProof as the input, printing the element each iteration.

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.

A pure const function that runs over x amount of elements, does an arbitrary expensive check and when it passes it adds the property to an array, once complete it returns the array.
A pure const function that runs over the filtered array and does another expensive check per array element, and adds it to a local array, when done returning the array.

Then running the following:

An event that calls ForEachLoop with PureArrayProof as the input, printing the element each iteration.

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).

Tags