Optimising events with function binding

11
Official Construct Team Post
Ashley's avatar
Ashley
  • 30 May, 2018
  • 1,685 words
  • ~7-11 mins
  • 3,898 visits
  • 5 favourites

Shortly after finishing the expression-to-JavaScript compiler, I realised a couple of the performance tricks we used there could actually be applied to conditions and actions in a more limited way. In short, conditions and actions for System, or single-global objects (notably Function), can be optimised using a similar function binding trick that we used in the expression compiler. This is particularly beneficial for events with intensive loops or function calls.

The scope of this is more limited than the expression compiler, and this post is pretty technical, so I'm posting it to my own blog rather than the main Construct blog, but it's got some interesting details about further event performance improvements if you want to know more about that kind of thing!

How actions run

To simplify explaining the optimisation, I'll focus on actions, but it's also applicable to conditions.

When actions normally run the code does roughly this:

  1. Call the main engine 'run action' function
  2. Evaluate parameters of the action
  3. Identify the instance(s) to run the action on
  4. Call the plugin-specific action method for those instance(s) with the evaluated parameters

For System and single-global plugins like Function, there only ever exists one instance that actions could possibly run on. This is an interesting detail we can take advantage of to reduce the work involved in running the action.

Binding functions

Here's a quick reminder about how Javascript's function binding works. Basically it works like this:

// Suppose you have a call like this:
obj.method();

// If you do this it creates a new function:
const func = obj.method.bind(obj);

// which when called does the same thing:
func(); // does obj.method()

Since there is only ever one instance of System or single-global plugins that actions can be called on, we can bind its plugin-specific action method to its only instance. Now we have a simple function call like func() that can automatically run an action on the right instance. These days this is also very well optimised in modern JavaScript engines.

Optimising with no parameters

If an action has no parameters, then there's no need to pass anything to the action method - func() is the only call that's needed. We can bypass evaluating parameters entirely. We can also bypass identifying the instance to call the action on, since there's only one, and it was already bound to the action method. So in this case, we could reduce the entire engine "Run action" function to just call func().

Then there's a particularly interesting trick we can use based on the fact JavaScript is a dynamic language: we can replace the main engine 'Run action' function with the bound function! This means step 1 from how actions are run directly calls in to the plugin-specific action method for the right instance. It entirely bypasses the engine code for running actions, which is a nice performance win.

Optimising with a few parameters

If an action has only a few parameters, we can do another trick also used when compiling expressions to JavaScript. Suppose the action has only one parameter. Normally evaluating the parameters involves looping through all the available parameters, collecting the results in to an array, and then passing those as arguments to the action method. We can cut that out completely by creating a new function specially written to directly pass the first parameter to the bound function, something like this:

function CreateActionFunctionWith1Parameter()
{
	const firstParameter = GetParameter(0);

	return function RunActionWithOneParameter()
	{
		func(firstParameter.Get());
	}
}

Now if the engine calls CreateActionFunctionWith1Param, it returns a new function which automatically calls the action method with the first parameter. Now we can replace the engine "Run action" function with that function, and we completely bypass looping through parameters and collecting the results in to an array!

It's also interesting to note that with the expression-to-JavaScript compiler, firstParameter.Get() will itself call another specially-designed JavaScript function that evaluates the expression. So we are approaching a situation where there is almost no engine overhead to running the action.

This can be extended to multiple parameters, e.g. for 2 parameters:

function CreateActionFunctionWith2Parameters()
{
	const firstParameter = GetParameter(0);
	const secondParameter = GetParameter(1);

	return function RunActionWith2Parameters()
	{
		func(firstParameter.Get(), secondParameter.Get());
	}
}

Obviously this can't be extended infinitely, and there are likely diminishing returns when there are lots of parameters anyway. So this was only extended to three parameters, to cover comparison conditions (which have three parameters for first value, comparison operator, and second value).

Optimising with constant parameters

Another interesting detail is Construct can tell when an expression is constant. This is actually very common, such as if you pass a fixed number like 1 as a parameter to "Add to variable".

Additionally, function binding in JavaScript can optionally also bind parameters to the function, like this:

// Suppose you have a call like this:
obj.add(1);

// If you do this it creates a new function:
const func = obj.add.bind(obj, 1);

// which when called does the same thing:
func(); // does obj.add(1)

Note the bound function is called with no parameters, but has the effect of calling a function with parameters!

This means if an action's parameters are all constant, we can bind them with the function! For example with an action which takes a constant 1 as a parameter, we can create a new function that automatically calls the action on the right instance and with the right parameters, something like this:

const firstValue = GetParameter(0).Get();

const func = actionMethod.bind(instance, firstValue);

Then we can replace the engine "Run action" function with func, and we're back to directly calling the action method! There is not even a step in between to evaluate the parameters. So engine code can still be completely bypassed even when parameters are used, as long as they're constant.

Interestingly actions like Add 1 to Variable1 count as having both parameters constant, because Variable1 always refers to the same variable. So it can still get that parameter on startup and bind it to an action function.

The end result is that running a system or single-global condition or action with no parameters, or up to 3 constant parameters, has virtually no engine overhead.

Deduplicating bound functions

Whenever the engine binds a function, it remembers it in a cache with any parameters it was bound with. Then if the same function is being bound, it can return the same function from the cache. This eliminates making duplicate functions that do the same thing, reducing the memory usage.

The end result of this is any actions with the same constant parameters, like Add 1 to Variable1, throughout the entire project, all call exactly the same bound JavaScript function. That's a nice efficiency gain for events, particularly algorithmic events heavily using variables or function parameters in loops and functions.

Results

Since the benefits are limited only to System and single-global plugins, there are fewer cases where it brings a measurable benefit. Still if we re-run some of the performance tests from the original blog post where this makes a difference, we can see what kind of improvement it can bring. I've got four sets of results for these measurements: the original C2 runtime, the original C3 runtime as of r95 (labelled "C3"), the C3 runtime with the expression compiler as of r101.2 (labelled "C3+"), and the latest C3 runtime with this function binding improvement as of r102 (labelled "C3++").

First up, let's re-measure how many Repeat loop iterations can be run every tick and still hit 30 FPS. This test pretty much solely measures the engine overhead, so will show up the improvement clearly.

Thanks to the reduced overhead this boosts the loop performance by +32%. The expression compiler hardly helped with this test, but the new function binding optimisation helps a lot. This brings the C3 runtime to a total of nearly 4x faster than the C2 runtime.

Next up let's re-measure the primefind test which measures the number of iterations it can run in 10 seconds.

This is a great test for showing the improvement of each round of optimisation so far. The reduced engine overhead boosts intensive loop and function performance by +22%, bringing the total improvement to 3.3x faster than the C2 runtime.

Next up let's re-measure the function call overhead when naively calculating the 30th fibonacci number.

This test also clearly shows each round of optimisation. The reduced engine overhead boosts intensive function performance by 21%, bringing the total improvement to 3.9x faster than the C2 runtime.

Other tests like bunnymark don't show much of an improvement, because they don't intensively use System or single-global plugin events, so in this case the reduced overhead doesn't help much. It's mostly loops and functions that benefit from this.

Conclusion

The work on the expression-to-JavaScript compiler involved some interesting techniques that carried over nicely to a limited, but important, aspect of event performance. Events that use loops with thousands of iterations, or heavy use of functions, should see some good improvements. And you can try it out today in the r102 release!

This is largely possible because the improved architecture of the C3 runtime is much more amenable to carving out special code paths for maximum performance in certain situations, while keeping the codebase manageable. It also depends on the years of optimisation work that has gone in to modern JavaScript engines. A few years ago function binding was known to be slow in some engines; thanks to improvements such as the new TurboFan engine for V8 (used in Chrome), it's now well optimised so we can use it to boost performance in the runtime even further.

Further optimising code gets progressively more difficult as there are fewer and fewer options for optimisation, and there are always plenty of other things to be working on, so we'll probably move on to other things for the time being. Still, we may find other areas of the engine that can be improved, or new JavaScript developments in future that provide extra options. So hopefully that's not the end of the optimisation work!

Subscribe

Get emailed when there are new posts!

  • 12 Comments

  • Order by
Want to leave a comment? Login or Register an account!
  • That's really interesting. Good Job guys! I'm wondering... would an operational expression reap the benefits of these latest performance tweaks also? I did some test and didn't notice any significant difference between C2 and C3 runtime.

    dropbox.com/s/63bpkgyzpnahtb3/operational_test.c3p

    • A slight improvement though, but given the latest updates I would think this should be more. Shouldn't an operational expression be easily translatable to a javascript function?

      • [-] [+]
      • 1
      • Ashley's avatar
      • Ashley
      • Construct Team Founder
      • 1 points
      • (2 children)

      What do you mean by "operational"? Are you talking about the conditional expression? That is compiled to a JavaScript conditional expression, but the test is mainly bottlenecked on drawing and rotating instances, so it's not really a suitable test for measuring event performance.

      • Ahhh i see what you mean. I changed it to not involve any drawing. So now i got better performance in c3 than C2 runtime. almost double... :)

      • It's nice to see though that the performance of events is getting closer and closer to the performance of a behaviour doing similar things Keep it up!

  • That's it, I'm upgrading today.

  • I pass the UID of an object to just about all my functions and then select the object inside the function. Is there a way to optimize this?

  • Like the performance wasn't already insane enough :D

    It's amazing how much work you're putting into the engine!

  • What does this mean Ashley

    How must I write my events to benefit from this ?

      • [-] [+]
      • 1
      • Ashley's avatar
      • Ashley
      • Construct Team Founder
      • 1 points
      • (0 children)

      This doesn't affect how you make events. It's an internal feature of the engine that is applied automatically.

  • Are these benchmark files available anywhere? I would like to run those tests on various phones and also see how they are structured, maybe learn something new.

  • Thank you for the update on this kind of information. I am currently in process of optimizing my codes and this is helping already just reading a bit of it. :) MUCH appreciated and highly respected on my part :)