From 2a831c33d521bd828396a49af5ea990ac96f303c Mon Sep 17 00:00:00 2001
From: Patrick Doyle
Date: Sat, 16 Nov 2024 17:10:10 -0500
Subject: [PATCH] Memoize compiled Jackson (de)serializers.
I'm not sure why I need to memoize these myself; I thought Jackson would do it.
This reduces the benchmark score from about 7700us to 170us, a 45x improvement.
---
.../works/bosk/drivers/ForwardingDriver.java | 5 +
.../works/bosk/jackson/JacksonCompiler.java | 1 +
.../works/bosk/jackson/JacksonPlugin.java | 16 ++-
.../jackson/JacksonRoundTripBenchmark.java | 107 ++++++++++++++++++
4 files changed, 127 insertions(+), 2 deletions(-)
create mode 100644 bosk-jackson/src/test/java/works/bosk/jackson/JacksonRoundTripBenchmark.java
diff --git a/bosk-core/src/main/java/works/bosk/drivers/ForwardingDriver.java b/bosk-core/src/main/java/works/bosk/drivers/ForwardingDriver.java
index ae976eef..6fea08a7 100644
--- a/bosk-core/src/main/java/works/bosk/drivers/ForwardingDriver.java
+++ b/bosk-core/src/main/java/works/bosk/drivers/ForwardingDriver.java
@@ -4,6 +4,7 @@
import java.lang.reflect.Type;
import lombok.RequiredArgsConstructor;
import works.bosk.BoskDriver;
+import works.bosk.DriverFactory;
import works.bosk.Identifier;
import works.bosk.Reference;
import works.bosk.StateTreeNode;
@@ -21,6 +22,10 @@
public class ForwardingDriver implements BoskDriver {
protected final BoskDriver downstream;
+ public static DriverFactory factory() {
+ return (b,d) -> new ForwardingDriver(d);
+ }
+
@Override
public StateTreeNode initialRoot(Type rootType) throws InvalidTypeException, IOException, InterruptedException {
return downstream.initialRoot(rootType);
diff --git a/bosk-jackson/src/main/java/works/bosk/jackson/JacksonCompiler.java b/bosk-jackson/src/main/java/works/bosk/jackson/JacksonCompiler.java
index 60015c66..1878671a 100644
--- a/bosk-jackson/src/main/java/works/bosk/jackson/JacksonCompiler.java
+++ b/bosk-jackson/src/main/java/works/bosk/jackson/JacksonCompiler.java
@@ -66,6 +66,7 @@ final class JacksonCompiler {
* @return a newly compiled {@link CompiledSerDes} for values of the given nodeType
.
*/
public CompiledSerDes compiled(JavaType nodeType, BoskInfo> boskInfo, JacksonPlugin.FieldModerator moderator) {
+ LOGGER.debug("Compiling SerDes for node type {}", nodeType);
try {
// Record that we're compiling this one to avoid infinite recursion
compilationsInProgress.get().addLast(nodeType);
diff --git a/bosk-jackson/src/main/java/works/bosk/jackson/JacksonPlugin.java b/bosk-jackson/src/main/java/works/bosk/jackson/JacksonPlugin.java
index e1e3744e..1baae9a1 100644
--- a/bosk-jackson/src/main/java/works/bosk/jackson/JacksonPlugin.java
+++ b/bosk-jackson/src/main/java/works/bosk/jackson/JacksonPlugin.java
@@ -36,6 +36,7 @@
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import works.bosk.BoskInfo;
import works.bosk.Catalog;
import works.bosk.Entity;
@@ -97,14 +98,19 @@ public void setupModule(SetupContext context) {
private final class BoskSerializers extends Serializers.Base {
private final BoskInfo> boskInfo;
+ private final Map> memo = new ConcurrentHashMap<>();
public BoskSerializers(BoskInfo> boskInfo) {
this.boskInfo = boskInfo;
}
@Override
- @SuppressWarnings({ "unchecked", "rawtypes" })
public JsonSerializer> findSerializer(SerializationConfig config, JavaType type, BeanDescription beanDesc) {
+ return memo.computeIfAbsent(type, __ -> getJsonSerializer(config, type, beanDesc));
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ private JsonSerializer> getJsonSerializer(SerializationConfig config, JavaType type, BeanDescription beanDesc) {
Class theClass = type.getRawClass();
if (theClass.isAnnotationPresent(DerivedRecord.class)) {
return derivedRecordSerializer(config, type, beanDesc);
@@ -308,14 +314,19 @@ public JsonSerializer> findMapSerializer(SerializationConfig config, MapType t
private final class BoskDeserializers extends Deserializers.Base {
private final BoskInfo> boskInfo;
+ private final Map> memo = new ConcurrentHashMap<>();
public BoskDeserializers(BoskInfo> boskInfo) {
this.boskInfo = boskInfo;
}
@Override
- @SuppressWarnings({ "unchecked", "rawtypes" })
public JsonDeserializer> findBeanDeserializer(JavaType type, DeserializationConfig config, BeanDescription beanDesc) {
+ return memo.computeIfAbsent(type, __ -> getJsonDeserializer(type, config, beanDesc));
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ private JsonDeserializer> getJsonDeserializer(JavaType type, DeserializationConfig config, BeanDescription beanDesc) {
Class theClass = type.getRawClass();
if (theClass.isAnnotationPresent(DerivedRecord.class)) {
return derivedRecordDeserializer(type, config, beanDesc);
@@ -662,6 +673,7 @@ private static void writeEntryAsField(JsonGenerator gen, Optional LinkedHashMap readMapEntries(JsonParser p, JavaType valueType, DeserializationContext ctxt) throws IOException {
+ @SuppressWarnings("unchecked")
JsonDeserializer valueDeserializer = (JsonDeserializer) ctxt.findContextualValueDeserializer(valueType, null);
LinkedHashMap result = new LinkedHashMap<>();
if (p.currentToken() == START_OBJECT) {
diff --git a/bosk-jackson/src/test/java/works/bosk/jackson/JacksonRoundTripBenchmark.java b/bosk-jackson/src/test/java/works/bosk/jackson/JacksonRoundTripBenchmark.java
new file mode 100644
index 00000000..c00260a3
--- /dev/null
+++ b/bosk-jackson/src/test/java/works/bosk/jackson/JacksonRoundTripBenchmark.java
@@ -0,0 +1,107 @@
+package works.bosk.jackson;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.concurrent.atomic.AtomicReference;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+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.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import works.bosk.AbstractBoskTest;
+import works.bosk.AbstractRoundTripTest;
+import works.bosk.Bosk;
+import works.bosk.BoskDriver;
+import works.bosk.DriverStack;
+import works.bosk.Identifier;
+import works.bosk.Path;
+import works.bosk.Reference;
+import works.bosk.exceptions.InvalidTypeException;
+
+import static java.util.concurrent.TimeUnit.MICROSECONDS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static org.openjdk.jmh.annotations.Mode.AverageTime;
+import static works.bosk.jackson.JacksonPluginConfiguration.defaultConfiguration;
+
+@Fork(0)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 10, time = 1)
+@OutputTimeUnit(MICROSECONDS)
+public class JacksonRoundTripBenchmark extends AbstractRoundTripTest {
+
+ @State(Scope.Benchmark)
+ public static class BenchmarkState {
+ private Bosk bosk;
+ private JacksonPlugin jacksonPlugin;
+ private ObjectMapper mapper;
+ private BoskDriver driver;
+ private BoskDriver downstreamDriver;
+ private Bosk.ReadContext context;
+ private Reference rootRef;
+ private Reference ref5Segments;
+ private TestRoot root1, root2;
+ private ThreadLocal threadLocalRoot;
+
+ final Identifier parentID = Identifier.from("parent");
+ final Identifier child1ID = Identifier.from("child1");
+
+ @Setup(Level.Trial)
+ public void setup() throws InvalidTypeException, JsonProcessingException {
+ AtomicReference downstreamRef = new AtomicReference<>();
+ this.bosk = setUpBosk(DriverStack.of(
+ jacksonRoundTripFactory(defaultConfiguration()),
+ (b,d) -> {
+ downstreamRef.set(d);
+ return d;
+ }
+ ));
+ this.driver = bosk.driver();
+ this.downstreamDriver = downstreamRef.get();
+ this.jacksonPlugin = new JacksonPlugin();
+ this.mapper = new ObjectMapper().registerModule(jacksonPlugin.moduleFor(bosk));
+ context = bosk.readContext();
+ rootRef = bosk.rootReference();
+ TestRoot localRoot = root1 = rootRef.value();
+ threadLocalRoot = ThreadLocal.withInitial(() -> localRoot);
+ ref5Segments = bosk.rootReference().then(TestEnum.class, Path.of(
+ TestRoot.Fields.entities, "parent",
+ AbstractBoskTest.TestEntity.Fields.children, "child1",
+ AbstractBoskTest.TestChild.Fields.testEnum
+ ));
+
+ // Make a separate identical state object, cloning via JSON
+ String json = mapper.writerFor(rootRef.targetClass()).writeValueAsString(root1);
+ root2 = mapper.readerFor(rootRef.targetClass()).readValue(json);
+ }
+
+ @TearDown(Level.Trial)
+ public void closeReadContext() {
+ context.close();
+ }
+
+ }
+
+ @Benchmark
+ @BenchmarkMode(AverageTime)
+ public void replacementOverhead(BenchmarkState state) {
+ state.downstreamDriver.submitReplacement(state.rootRef, state.root2);
+ state.downstreamDriver.submitReplacement(state.rootRef, state.root1);
+ }
+
+ @Benchmark
+ @BenchmarkMode(AverageTime)
+ public void replacement(BenchmarkState state) {
+ state.driver.submitReplacement(state.rootRef, state.root2);
+ state.driver.submitReplacement(state.rootRef, state.root1);
+ }
+
+}