Request: Ability to Fire Component Events using String Payloads

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 :man_shrugging:.

My guess is that the significant difference is because it's not apples to apples.
pyToGson has to allocate the thousands of individual JsonElements 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 :person_shrugging:

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 :slight_smile:

(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.