Optimizing TorqueScript

For the past few weeks one of my side-projects has been to improve TorqueScript, the scripting language behind the Torque3D game engine.

TorqueScript itself has a long history, going all the way back to Tribes 1 in the form of TribesScript. After years of maturing, one might thing this is an optimized world class game scripting language. Sadly this is not the case.

I came to this conclusion after creating a basic series of tests which benchmark a simple case of adding numbers together 1000000 times in TorqueScript.

For reference, the initial results I got for these tests were:

# Runtimes are in milliseconds
Test,1,2,3,4,5,AVG
scriptTest1,6880,6464,6496,6464,6464,6553.6
scriptTest2,640,672,640,640,640,646.4
scriptTest3,4960,4832,4832,4832,4832,4857.6
scriptTest4,512,512,512,512,512,512
scriptTest5,3104,3136,3104,3136,3136,3123.2
scriptTest6,256,256,288,256,256,262.4

Compare that to the sort of times you get from Lua ( ignoring tests 4 and 5 which tested bindings ):

TEST,1,2,3,4,5,AVG
scriptTest1,177.313000,173.866000,173.777000,173.635000,172.777000,174.273600
scriptTest2,365.673000,366.093000,366.950000,367.679000,373.810000,368.041000
scriptTest3,495.440000,493.399000,490.451000,492.778000,491.064000,492.626400
scriptTest6,151.421000,150.907000,150.767000,149.743000,151.732000,150.914000

That is a shocking ~37x difference in speed.

The problems

In TorqueScript, variables can be one of three things: an integer, a floating point number, or a string. Integers are commonly used to perform bitwise operations, while floating point numbers are used for math. You can also have binded variables which work as complex native types, though they are converted to and from strings when manipulated from script.

Most of the time, a variable ends up being a string since that is the only default assumption which can be made. For instance, if we assign a local variable like this:

%variable = %otherVariable;

It will generate a byte-code sequence like this:

OP_SETCURVAR "otherVariable"
OP_LOADVAR_STR
OP_SETCURVAR_CREATE "variable"
OP_SAVEVAR_STR

In essence this will convert “otherVariable” to a string, regardless of its original type. This is not too much of a problem, as the integer and float values are stored along with the string value. However if we do something like this:

%variable = %variable + 1;

It’s quite clear to the compiler that %variable should be a floating point number so it will generate a byte-code sequence something like this:

OP_LOADIMMED_FLT 1
OP_SETCURVAR "variable"
OP_LOADVAR_FLT
OP_ADD
OP_SAVEVAR_FLT

A problem comes with the OP_SAVEVAR_FLT opcode. This will destroy the string value of the variable and change its value to a floating point number. If we do something like this again:

%variable = %otherVariable;

It will convert the variable back to a string (allocating memory in the process), so in essence one ends up with a variable being dragged between two states: being a string and being a number.

“But wait a minute,” you may think. “most of the time it’s clear that a variable should be a number.” This is true until we get to function calls. In TorqueScript all function parameters are passed as strings (even binded functions), so if you do this:

doThis(%currentVariable, 10, 20, "MyName");

For each parameter it will copy its string value onto the “String Stack”. When the function is finally executed, it will copy the variables from the string stack and assign them to local variables, or in the case of a binded function it will just supply you with a series of strings pointing to the stack. Upon returning, the return value will usually be converted to a string.

So not only do we have to contend with numeric variables being strings in the function, we also have to keep in mind that any string passed in will likely be duplicated twice too.

For any scripting interpreter, allocating and deallocating memory unnecessarily can be very costly. For Torque3D, this problem is compounded by the fact that it by default uses the system memory allocator (malloc/free) which can differ greatly across platforms. In addition there is no Garbage Collection, so everything is deallocated as soon as it is disposed of.

The solution

So what’s the solution to this mess? For starters, optimizing variable assignment helps. Creating “load variable” and “save variable” opcodes so “%variable = %otherVariable” turns into this:

OP_SETCURVAR "otherVariable"
OP_LOADVAR_VAR
OP_SETCURVAR_CREATE "variable"
OP_SAVEVAR_VAR

Ends up making a measurable difference in performance when assigning lots of numeric variables.

Next, function calls. Instead of converting every parameter and return value to a string, wrapping them in a “Console value” class to eliminate unnecessary type conversions which transparently works like this:

ConsoleValueRef argv = {"arg1", 2, 3};
argv[0] // "argv1"
argv[1] // 2
argv[2] // 3

Brings the biggest speed improvement of them all. With these two basic improvements, I managed to improve the runtime speed by 8x in the best case:

Test,1,2,3,4,5,AVG
scriptTest1,784,752,768,736,752,758.4
scriptTest2,976,944,960,944,960,956.8
scriptTest3,1200,1216,1200,1200,1200,1203.2
scriptTest4,448,432,464,432,448,444.8
scriptTest5,944,928,960,928,944,940.8
scriptTest6,288,320,304,320,304,307.2
FIN

This is sadly where I decided to stop. The next major improvement, to improve object field access (e.g. “object.foo”), would have required a complete overhaul of the type system. For now, I’m just happy I got a nice speed improvement.

It’s somewhat surprising that despite ~14 years since inception, nobody else seems to have taken the step of making this basic beneficial improvement to TorqueScript.

For reference, my code with the optimizations is available here