Description
(NOTE: A repository with code that demonstrates the issue is here: js-engine-performance)
I'm evaluating the feasibility of moving from using Rhino to GraalJS. The scenario is user defined expressions are being evaluated in a Context that has a number of variables bound to it, each expression may or may not refer to some of the bound variables. A Context may be short-lived, so it's important that creating them and binding variables is speedy - unfortunately both creating a polyglot context and binding variables in it is significantly slower than using Rhino.
I believe the issue with slow Context creation can be alleviated by using a pool of Contexts rather then creating a new one each time. The code linked above is somewhat simpler, as it's only running single-threaded and so can simply clear out the bindings and reuse a single Context.
The bindings problem is a thornier issue. The linked code times how long it takes to do 30,000: context creation; variable bind (100 variables); expression evaluation; context close.
On my machine this takes Rhino approximately 0.2 seconds, but GraalJS takes around 20 seconds.
In the case where an expression on references a small number of variables, performance can be improved by pre-parsing the expression to be evaluated and only binding referenced variables (the example code does very basic parsing and assumes each variable is surrounded by white space). Rather than having to parse expressions before evaluation, is there a way in which a variable can be bound at the point the engine tries to access a value and finds it's not bound? Better still, is there any way to make binding faster?
I'm running using:
java version "21.0.1" 2023-10-17
Java(TM) SE Runtime Environment Oracle GraalVM 21.0.1+12.1 (build 21.0.1+12-jvmci-23.1-b19)
Java HotSpot(TM) 64-Bit Server VM Oracle GraalVM 21.0.1+12.1 (build 21.0.1+12-jvmci-23.1-b19, mixed mode, sharing)
With version 23.1.1 of the Graal libraries.
Full code is in the linke repo, but as an outline:
I have an Evaluator interface, the GraalJs implementation is created using the following factory:
private static Supplier<Evaluator> graalEvaluatorFactory(Function<String, Source> sourceFactory) {
Context.Builder ctxBuilder = Context.newBuilder("js").engine(Engine.create("js"));
// Use the same Context for every Evaluator
Context ctx = ctxBuilder.build();
Value bindings = ctx.getBindings("js");
return () -> {
return new Evaluator() {
@Override
public void close() {
// Creating a context is slow, so simply clear out the bindings so it can be
// reused.
// NOTE: This is only OK because we're running in a single thread.
//
// NOTE: bindings.getMemberKeys().clear() throws an exception, as does
// using an Iterator and calling remove().
bindings.getMemberKeys().forEach(s -> bindings.removeMember(s));
}
@Override
public Object eval(String snippet) {
return ctx.eval(sourceFactory.apply(snippet)).as(Object.class);
}
@Override
public void bind(String name, Object value) {
bindings.putMember(name, value);
}
};
};
}
The sourceFatory is one of these (performance of both is similar):
private static final Function<String, Source> nonCachingSourceFactory() {
return snippet -> Source.create("js", snippet);
}
private static final Function<String, Source> cachingSourceFactory() {
Map<String, Source> sources = new HashMap<>();
return snippet -> sources.computeIfAbsent(snippet, s -> {
System.out.println("Caching: " + s);
return Source.create("js", s);
});
}
With a "binder" to bind values defined in a variables map:
Map<String, Object> variables = variables();
// Bind all variables in the Map into an Evaluator
BiConsumer<Evaluator, String> bindAll =
(evaluator, snippet) -> variables.forEach((name, value) -> evaluator.bind(name, value));
// Iterate over extracted tokens and bind if they have a value in the variables
// map
BiConsumer<Evaluator, String> bindReferenced = (evaluator, snippet) -> {
tokensFromSnippet(snippet).forEach(token -> {
if (variables.containsKey(token)) {
evaluator.bind(token, variables.get(token));
}
});
};
With a single evaluation being performed thus:
private static Object eval(Supplier<Evaluator> evaluatorFactory,
BiConsumer<Evaluator, String> binder, String snippet) {
try (Evaluator evaluator = evaluatorFactory.get()) {
binder.accept(evaluator, snippet);
return evaluator.eval(snippet);
}
}