Testing Groovy/JVM scripting engine performance
(Warning: very technical gamedev post)
I did some quick testing on the performance of Java and Groovy (my preferred scripting engine), to see where various game logic should occur, if it’s being called potentially tens of thousands of times per second.
Test specifications
Using Java 1.8 on Windows, Groovy 3.0.8, for 300k iterations of a simple a < b
check. I ran the benchmark five times
so that the JVM and Groovy could warm up a bit first, but capturing the warmup/startup time is still useful, too.
Method | First run, ms | Final run, ms |
---|---|---|
a < b |
19 | 1 |
get("a") < get("b") |
6 | 3 |
op(get("a"), get("b"), "<") |
13 | 3 |
eval("a < b", sharedBindings) |
1422 | 622 |
eval("a < b", new Bindings(...)) |
699 | 688 |
eval("return get('a') < get('b')", sharedBindings) |
837 | 772 |
eval("return get('a') < get('b')", new Bindings(...)) |
782 | 778 |
Summary
As soon as you enter the Groovy scripting engine – at least the way that I’m doing it – performance slows down by about ~100x.
Jumping back into the JVM to get data slows execution down by about 10%, so it’s better to put data into the bindings than to jump out of the Groovy scripting context.
Source
Using the following source file:
public class GroovyPerformanceTest {
public static int runs = 5;
public static long iterations = 300000l;
public static void main(String[] arg) throws ScriptException {
new GroovyPerformanceTest().run();
}
public void run() throws ScriptException {
long time = System.currentTimeMillis();
for (int i = 0; i < runs; i++) {
// --
for (long j = 0; j < iterations; j++) {
expect(doInlineJava(i, j));
}
System.out.println("doInlineJava -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doInlineJavaWithSomeSelectSwitches(i, j));
}
System.out.println("doInlineJavaWithSomeSelectSwitches -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doInlineJavaWithSomeSelectSwitchesAndSelectOperation(i, j));
}
System.out.println("doInlineJavaWithSomeSelectSwitchesAndSelectOperation -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doGroovyExecutionSharedBindings(i, j));
}
System.out.println("doGroovyExecutionSharedBindings -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doGroovyExecutionNewBindings(i, j));
}
System.out.println("doGroovyExecutionNewBindings -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doGroovyExecutionWithSelectionBackIntoJvm(i, j));
}
System.out.println("doGroovyExecutionWithSelectionBackIntoJvm -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
// --
for (long j = 0; j < iterations; j++) {
expect(doGroovyExecutionWithSelectionBackIntoJvmNewBindings(i, j));
}
System.out.println("doGroovyExecutionWithSelectionBackIntoJvmNewBindings -> " + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
}
}
private void expect(boolean b) {
if (!b) {
throw new IllegalStateException();
}
}
public boolean doInlineJava(int i, long j) {
double a = 1.0 + MathUtils.random(1f);
double b = 2.0 + MathUtils.random(1f);
return a < b;
}
public boolean doInlineJavaWithSomeSelectSwitches(int i, long j) {
double a = getSelectValue("a");
double b = getSelectValue("b");
return a < b;
}
public boolean doInlineJavaWithSomeSelectSwitchesAndSelectOperation(int i, long j) {
double a = getSelectValue("a");
double b = getSelectValue("b");
return doOperation(a, b, "<");
}
private double getSelectValue(String s) {
switch (s) {
case "a": return 1.0 + MathUtils.random(1f);
case "b": return 2.0 + MathUtils.random(1f);
case "c": return 3.0 + MathUtils.random(1f);
case "d": return 4.0 + MathUtils.random(1f);
case "e": return 5.0 + MathUtils.random(1f);
case "f": return 6.0 + MathUtils.random(1f);
case "g": return 7.0 + MathUtils.random(1f);
case "h": return 8.0 + MathUtils.random(1f);
case "i": return 9.0 + MathUtils.random(1f);
case "j": return 10.0 + MathUtils.random(1f);
default: throw new IllegalArgumentException(s);
}
}
private boolean doOperation(double a, double b, String op) {
switch (op) {
case "<": return a < b;
case ">": return a > b;
default: throw new IllegalArgumentException(op);
}
}
private SimpleBindings bindings;
private void initBindings(SimpleBindings bindings) {
bindings.put("a", getSelectValue("a"));
bindings.put("b", getSelectValue("b"));
bindings.put("c", getSelectValue("c"));
bindings.put("d", getSelectValue("d"));
bindings.put("e", getSelectValue("e"));
bindings.put("f", getSelectValue("f"));
bindings.put("g", getSelectValue("g"));
bindings.put("h", getSelectValue("h"));
bindings.put("i", getSelectValue("i"));
bindings.put("j", getSelectValue("j"));
}
public boolean doGroovyExecutionSharedBindings(int i, long j) throws ScriptException {
if (bindings == null) {
bindings = new SimpleBindings();
initBindings(bindings);
}
return (boolean) requireNonNull(eval("return a < b", bindings));
}
public boolean doGroovyExecutionNewBindings(int i, long j) throws ScriptException {
SimpleBindings localBindings = new SimpleBindings();
initBindings(localBindings);
return (boolean) requireNonNull(eval("return a < b", localBindings));
}
private SimpleBindings returnBindings;
/** Callback with a string returning an Object. */
@FunctionalInterface
public static interface StringReturningDoubleCallback {
double call(String s);
}
public boolean doGroovyExecutionWithSelectionBackIntoJvm(int i, long j) throws ScriptException {
if (returnBindings == null) {
returnBindings = new SimpleBindings();
returnBindings.put("get", new StringReturningDoubleCallback() {
@Override
public double call(String s) {
return getSelectValue(s);
}
});
}
return (boolean) requireNonNull(eval("return get('a') < get('b')", returnBindings));
}
public boolean doGroovyExecutionWithSelectionBackIntoJvmNewBindings(int i, long j) throws ScriptException {
SimpleBindings localBindings = new SimpleBindings();
localBindings.put("get", new StringReturningDoubleCallback() {
@Override
public double call(String s) {
return getSelectValue(s);
}
});
return (boolean) requireNonNull(eval("return get('a') < get('b')", localBindings));
}
/**
* Scripting engine. Cached between calls so that we don't have to continually
* reinit it (it takes a few seconds to create).
*/
private static GroovyScriptEngineImpl engine = null;
private static @Nullable Object eval(String command, Bindings bindings) throws ScriptException {
if (engine == null) {
System.out.println("Spawning Groovy engine...");
ScriptEngineManager manager = new ScriptEngineManager();
engine = (GroovyScriptEngineImpl) manager.getEngineByName("groovy");
}
engine.setBindings(bindings, ScriptContext.GLOBAL_SCOPE);
return engine.eval(command, bindings);
}
}