When sending large messages containing user-provided Python content to Perspective components/pages, the overhead of creating a single use JsonObject
can be quite large, just for it to be immediately serialized into a string.
Test
# The message body to be sent.
message = {
'param1': [ { "items": [j for j in range(40)] } for i in range(1000) ]
}
Method 1
Create a JsonObject
, and use TypeUtilities.pyToGson
to convert the message
parameter.
Send the message via PageModel.send
using the JsonElement
overload.
Method 2
Use a JsonWriter
and SystemUtilities.jsonEncode
to directly create the message as a string.
Send the message via PageMode.send
using the String
overload.
Method |
Time To Serialize + Send Message |
Intermediate JsonObject |
~190ms |
JsonWriter |
~6ms |
My Request
There is no ComponentModelDelegate.fireEvent
overload that takes a string. You have to provide a JsonObject
.
Can we get an overload that takes a string?
I can make an Idea's Portal request if it's important, but I think this is too niche to get any traction there.
Edit: Eh, maybe I requested too early in my though process.
You can get the same performance boost by using SystemUtilities.jsonEncode
to add the message to the JsonObject
, then using JSON.parse
on the client. So maybe this is really a PSA that SystemUtilities.jsonEncode
is much faster than TypeUtilities.pyToGson
.
My guess is that the significant difference is because it's not apples to apples.
pyToGson
has to allocate the thousands of individual JsonElement
s internally and return them to you as a rich object graph. jsonEncode
gets to just spit out strings - no extra allocation churn required.
1 Like
Although, maybe it's not just that...
Even using a JsonWriter directly, which is fairly unpleasant, and avoiding intermediate allocations, it's still a lot slower:
Benchmark Mode Cnt Score Error Units
PyJsonBenchmark.customPyToGson avgt 10 47.964 ± 2.301 ms/op
PyJsonBenchmark.jsonEncode avgt 10 2.207 ± 0.262 ms/op
PyJsonBenchmark.pyToGson avgt 10 67.329 ± 3.034 ms/op
Dunno.
JMH Test
package com.inductiveautomation.common.script.bench;
import java.io.IOException;
import java.io.Writer;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import com.inductiveautomation.ignition.common.PyUtilities;
import com.inductiveautomation.ignition.common.TypeUtilities;
import com.inductiveautomation.ignition.common.gson.Gson;
import com.inductiveautomation.ignition.common.gson.stream.JsonWriter;
import com.inductiveautomation.ignition.common.script.builtin.SystemUtilities;
import org.apache.commons.io.output.StringBuilderWriter;
import org.intellij.lang.annotations.Language;
import org.json.JSONException;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.python.core.CompileMode;
import org.python.core.CompilerFlags;
import org.python.core.Py;
import org.python.core.PyCode;
import org.python.core.PyObject;
import org.python.core.PyStringMap;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 10, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(1)
@Threads(1)
public class PyJsonBenchmark {
@State(Scope.Benchmark)
public static class BenchmarkState {
PyObject list;
final Gson gson = new Gson();
@Setup
public void setup() {
@Language("python")
String script = """
{
'param1': [ { "items": [j for j in xrange(40)] } for i in xrange(1000) ]
}
""";
list = eval(script);
}
private PyObject eval(@Language("python") String script) {
PyStringMap globals = exec(Py.newString("__RESULT") + " = " + script);
return globals.get(Py.newString("__RESULT"));
}
private PyStringMap exec(@Language("python") String script) {
PyCode compiledCall = compile("<test>", script);
PyStringMap globals = new PyStringMap();
Py.runCode(compiledCall, null, globals);
return globals;
}
private PyCode compile(String filename, @Language("python") String script) {
return Py.compile_flags(
script,
filename,
CompileMode.exec,
CompilerFlags.getCompilerFlags().combine(CompilerFlags.PyCF_SOURCE_IS_UTF8)
);
}
}
@Benchmark
public String jsonEncode(BenchmarkState state) throws JSONException {
return SystemUtilities.jsonEncode(state.list);
}
@Benchmark
public String pyToGson(BenchmarkState state) {
return TypeUtilities.pyToGson(state.list).toString();
}
@Benchmark
public String customPyToGson(BenchmarkState state) throws IOException {
Writer stringWriter = new StringBuilderWriter();
try (JsonWriter writer = new JsonWriter(stringWriter)) {
pyToGsonWriter(state.list, writer, state.gson);
}
return stringWriter.toString();
}
public static void pyToGsonWriter(@Nullable PyObject pyObject, JsonWriter writer, Gson gson) throws IOException {
if (pyObject == null || Py.None.equals(pyObject)) {
writer.nullValue();
return;
}
if (PyUtilities.isSequence(pyObject)) {
writer.beginArray();
for (PyObject item : pyObject.asIterable()) {
pyToGsonWriter(item, writer, gson);
}
writer.endArray();
return;
} else if (pyObject.isMappingType()) {
writer.beginObject();
for (PyObject key : pyObject.asIterable()) {
writer.name(key.toString());
pyToGsonWriter(pyObject.__getitem__(key), writer, gson);
}
writer.endObject();
return;
} else if (PyUtilities.isIterable(pyObject) && !PyUtilities.isString(pyObject)) {
writer.beginArray();
for (PyObject item : pyObject.asIterable()) {
pyToGsonWriter(item, writer, gson);
}
writer.endArray();
return;
}
Object o = pyObject.__tojava__(Object.class);
if (o instanceof String string) {
writer.value(string);
} else if (o instanceof Number number) {
writer.value(number);
} else if (o instanceof Boolean bool) {
writer.value(bool);
} else if (o == null) {
writer.nullValue();
} else {
gson.toJson(o, o.getClass(), writer);
}
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(PyJsonBenchmark.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
1 Like
100%, I was talking specifically in the context of preparing Perspective messages where the goal is to immediately serialize to a string for sending over the WebSocket.
I knew that building an single-use JsonObject
had to be slower, but I didn't realize how much performance was left on the table.
For what it's worth,
ComponentModelDelegate.fireEvent
is just a simple cover method:
public void fireEvent(String eventName, JsonObject event) {
component.fireEvent(EventConfig.MODEL_EVENTS, eventName, event);
}
And the underlying fireEvent
is accepting Object
. Now, we're still going to rinse that through JSON, so you may not actually gain anything, but you could in theory pass a raw string there directly?
Huh. So, I found out why (besides the allocation) it's so much slower:
Benchmark Mode Cnt Score Error Units
PyJsonBenchmark.customPyToGson avgt 10 3.835 ± 0.079 ms/op
PyJsonBenchmark.jsonEncode avgt 10 2.107 ± 0.034 ms/op
PyJsonBenchmark.pyToGson avgt 10 66.651 ± 1.742 ms/op
The culprit is PyUtilities#isIterable
, which, because duck typing, relies on exception catching around the __iter__
dunder method:
public static boolean isIterable(PyObject pyObject) {
try {
pyObject.__iter__();
return true;
} catch (Exception ignored) {
return false;
}
}
Which is naturally quite slow, because (among other things) it's building a real stack trace.
If you just drop that check to handle unbounded sequences/generator expressions in the pyToGson
duplicate:
@Benchmark
public String customPyToGson(BenchmarkState state) throws IOException {
Writer stringWriter = new StringBuilderWriter();
try (JsonWriter writer = new JsonWriter(stringWriter)) {
pyToGsonWriter(state.list, writer, state.gson);
}
return stringWriter.toString();
}
public static void pyToGsonWriter(@Nullable PyObject pyObject, JsonWriter writer, Gson gson) throws IOException {
if (pyObject == null || Py.None.equals(pyObject)) {
writer.nullValue();
return;
}
if (PyUtilities.isSequence(pyObject)) {
writer.beginArray();
for (PyObject item : pyObject.asIterable()) {
pyToGsonWriter(item, writer, gson);
}
writer.endArray();
return;
} else if (pyObject.isMappingType()) {
writer.beginObject();
for (PyObject key : pyObject.asIterable()) {
writer.name(key.toString());
pyToGsonWriter(pyObject.__getitem__(key), writer, gson);
}
writer.endObject();
return;
}
// } else if (PyUtilities.isIterable(pyObject) && !PyUtilities.isString(pyObject)) {
// writer.beginArray();
// for (PyObject item : pyObject.asIterable()) {
// pyToGsonWriter(item, writer, gson);
// }
// writer.endArray();
// return;
// }
Object o = pyObject.__tojava__(Object.class);
if (o instanceof String string) {
writer.value(string);
} else if (o instanceof Number number) {
writer.value(number);
} else if (o instanceof Boolean bool) {
writer.value(bool);
} else if (o == null) {
writer.nullValue();
} else {
gson.toJson(o, o.getClass(), writer);
}
}
You get the results at the beginning. Might be worth keeping around in a back pocket 
1 Like
When sending large messages containing user-provided Python content to Perspective components/pages
ok now I have to know why this is a feature you're playing with 
(Also, I wonder if you could manually construct the json object with allocators?)
Nothing new, just looking to eke out some more performance from Embr's system.perspective.runJavaScript
functions and JavaScriptProxy
objects.
Both features allow users to supply dictionaries of parameters along with their JavaScript functions, and if you're trying to send data in chunks for Embr Charts, every millisecond counts.