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); + } + +}