diff --git a/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonEncodingRegressionTest.java b/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonEncodingRegressionTest.java index 9274ba3..92cb45c 100644 --- a/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonEncodingRegressionTest.java +++ b/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonEncodingRegressionTest.java @@ -10,6 +10,7 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import com.google.protobuf.*; +import com.google.protobuf_test_messages.proto3.TestMessagesProto3.TestAllTypesProto3; import com.google.protobuf.util.JsonFormat; import org.junit.jupiter.api.Test; @@ -109,6 +110,20 @@ void wellKnownTypesMatchForConcreteAndDynamicMessages() throws Exception { } } + @Test + void repeatedTimestampAndDurationMatchAcrossAllPaths() throws Exception { + var original = TestAllTypesProto3.newBuilder() + .addRepeatedTimestamp(Timestamp.newBuilder().setSeconds(-1).setNanos(123456789)) + .addRepeatedTimestamp(Timestamp.newBuilder().setSeconds(1711627200).setNanos(123000000)) + .addRepeatedDuration(Duration.newBuilder().setSeconds(-2).setNanos(-123456789)) + .addRepeatedDuration(Duration.newBuilder().setSeconds(3).setNanos(250000000)).build(); + String expected = PRINTER.print(original); + for (var encoder : encoders()) { + assertEquals(expected, encoder.encode(original)); + assertEquals(expected, new String(encoder.encodeToBytes(original), StandardCharsets.UTF_8)); + } + } + @Test void booleanMapKeysStayBooleanNamesWithNumericBooleanFeature() throws Exception { var message = TestMaps.newBuilder().putBoolToString(true, "yes").putBoolToString(false, "no").build(); diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java index 29f64fd..dbcaa87 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java @@ -50,7 +50,8 @@ * For Timestamp and Duration, {@code writeTimestampDirect()} and * {@code writeDurationDirect()} accept primitive seconds/nanos directly, * bypassing descriptor lookup and {@code message.getField()} reflection. These - * are used by generated encoders that know the field type at generation time. + * are used by generated encoders and pre-specialized typed runtime accessors + * that know the field type before entering the hot write loop. * *

* Timestamp formatting uses Howard Hinnant's civil calendar algorithm to diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/FieldName.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/FieldName.java index c296493..452b40b 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/FieldName.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/FieldName.java @@ -6,8 +6,9 @@ import com.alibaba.fastjson2.JSONWriter; /** - * Pre-encoded field name in both UTF-16 (char[]) and UTF-8 (byte[]) forms. - * Dispatches to the optimal variant based on the JSONWriter type. + * Pre-encoded field name in both UTF-16 (char[]) and UTF-8 (byte[]) forms. The + * caller hoists the JSONWriter encoding check once per message and passes the + * selected form to {@link #writeTo(JSONWriter, boolean)}. */ public record FieldName(char[] chars, byte[] utf8) { @@ -28,8 +29,8 @@ public static FieldName of(String jsonName) { } } - public void writeTo(JSONWriter jw) { - if (jw.isUTF8()) + public void writeTo(JSONWriter jw, boolean isUtf8) { + if (isUtf8) jw.writeNameRaw(utf8); else jw.writeNameRaw(chars); diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessor.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessor.java index 9b298fe..2e20646 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessor.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessor.java @@ -12,7 +12,9 @@ import com.google.protobuf.Descriptors.EnumDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Descriptors.OneofDescriptor; +import com.google.protobuf.Duration; import com.google.protobuf.Message; +import com.google.protobuf.Timestamp; import io.suboptimal.buffjson.internal.FieldWriter; import io.suboptimal.buffjson.internal.ProtobufMessageWriter; @@ -30,7 +32,21 @@ */ public sealed interface TypedFieldAccessor { - void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer); + void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8); + + static void writeTimestampValue(JSONWriter jw, Message message, ProtobufMessageWriter writer) { + if (message instanceof Timestamp timestamp) + WellKnownTypes.writeTimestampDirect(jw, timestamp.getSeconds(), timestamp.getNanos()); + else + WellKnownTypes.write(jw, message, writer); + } + + static void writeDurationValue(JSONWriter jw, Message message, ProtobufMessageWriter writer) { + if (message instanceof Duration duration) + WellKnownTypes.writeDurationDirect(jw, duration.getSeconds(), duration.getNanos()); + else + WellKnownTypes.write(jw, message, writer); + } /** * Writes an enum value by name. Uses the pre-built dense name array for the @@ -53,11 +69,11 @@ static void writeEnumName(JSONWriter jw, int ev, String[] names, EnumDescriptor record IntAccessor(ToIntFunction getter, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { int v = getter.applyAsInt(msg); if (v == 0) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (unsigned) jw.writeInt64(Integer.toUnsignedLong(v)); else @@ -68,11 +84,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record LongAccessor(ToLongFunction getter, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { long v = getter.applyAsLong(msg); if (v == 0L) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (unsigned) WellKnownTypes.writeUnsignedLongString(jw, v); else @@ -82,11 +98,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record FloatAccessor(ToDoubleFunction getter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { float v = (float) getter.applyAsDouble(msg); if (Float.floatToRawIntBits(v) == 0) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (Float.isFinite(v)) jw.writeFloat(v); else if (Float.isNaN(v)) @@ -98,11 +114,11 @@ else if (Float.isNaN(v)) record DoubleAccessor(ToDoubleFunction getter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { double v = getter.applyAsDouble(msg); if (Double.doubleToRawLongBits(v) == 0) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (Double.isFinite(v)) jw.writeDouble(v); else if (Double.isNaN(v)) @@ -114,9 +130,9 @@ else if (Double.isNaN(v)) record BoolAccessor(Predicate getter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (getter.test(msg)) { - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeBool(true); } } @@ -124,22 +140,22 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record StringAccessor(Function getter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { String v = getter.apply(msg); if (v.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeString(v); } } record ByteStringAccessor(Function getter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { ByteString v = getter.apply(msg); if (v.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeBase64(v.toByteArray()); } } @@ -147,11 +163,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record EnumAccessor(ToIntFunction valueGetter, String[] names, EnumDescriptor enumType, boolean nullValue, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { int ev = valueGetter.applyAsInt(msg); if (ev == 0) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (nullValue) jw.writeNull(); else @@ -164,10 +180,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceIntAccessor(ToIntFunction getter, Predicate has, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); int v = getter.applyAsInt(msg); if (unsigned) jw.writeInt64(Integer.toUnsignedLong(v)); @@ -179,10 +195,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceLongAccessor(ToLongFunction getter, Predicate has, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); long v = getter.applyAsLong(msg); if (unsigned) WellKnownTypes.writeUnsignedLongString(jw, v); @@ -194,10 +210,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceFloatAccessor(ToDoubleFunction getter, Predicate has, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); float v = (float) getter.applyAsDouble(msg); if (Float.isFinite(v)) jw.writeFloat(v); @@ -211,10 +227,10 @@ else if (Float.isNaN(v)) record PresenceDoubleAccessor(ToDoubleFunction getter, Predicate has, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); double v = getter.applyAsDouble(msg); if (Double.isFinite(v)) jw.writeDouble(v); @@ -228,10 +244,10 @@ else if (Double.isNaN(v)) record PresenceBoolAccessor(Predicate getter, Predicate has, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeBool(getter.test(msg)); } } @@ -239,10 +255,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceStringAccessor(Function getter, Predicate has, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeString(getter.apply(msg)); } } @@ -250,10 +266,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceByteStringAccessor(Function getter, Predicate has, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.writeBase64(getter.apply(msg).toByteArray()); } } @@ -261,10 +277,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceEnumAccessor(ToIntFunction valueGetter, Predicate has, String[] names, EnumDescriptor enumType, boolean nullValue, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); if (nullValue) { jw.writeNull(); return; @@ -276,10 +292,10 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record PresenceMessageAccessor(Function getter, Predicate has, boolean wellKnown, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { if (!has.test(msg)) return; - name.writeTo(jw); + name.writeTo(jw, utf8); Message nested = getter.apply(msg); if (wellKnown) WellKnownTypes.write(jw, nested, writer); @@ -288,16 +304,38 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { } } + record PresenceTimestampAccessor(Function getter, Predicate has, + FieldName name) implements TypedFieldAccessor { + @Override + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { + if (!has.test(msg)) + return; + name.writeTo(jw, utf8); + writeTimestampValue(jw, getter.apply(msg), writer); + } + } + + record PresenceDurationAccessor(Function getter, Predicate has, + FieldName name) implements TypedFieldAccessor { + @Override + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { + if (!has.test(msg)) + return; + name.writeTo(jw, utf8); + writeDurationValue(jw, getter.apply(msg), writer); + } + } + // --- Repeated fields --- record RepeatedAccessor(Function> listGetter, FieldDescriptor fd, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = listGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); FieldWriter.writeRepeated(jw, fd, values, writer); } } @@ -306,11 +344,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record RepeatedIntAccessor(Function> listGetter, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = (List) (List) listGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startArray(); for (int i = 0; i < values.size(); i++) { if (i > 0) @@ -328,11 +366,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record RepeatedLongAccessor(Function> listGetter, boolean unsigned, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = (List) (List) listGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startArray(); for (int i = 0; i < values.size(); i++) { if (i > 0) @@ -349,11 +387,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { @SuppressWarnings("unchecked") record RepeatedStringAccessor(Function> listGetter, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = (List) (List) listGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startArray(); for (int i = 0; i < values.size(); i++) { if (i > 0) @@ -368,11 +406,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record RepeatedMessageAccessor(Function> listGetter, boolean wellKnown, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = (List) (List) listGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startArray(); for (int i = 0; i < values.size(); i++) { if (i > 0) @@ -387,15 +425,53 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { } } + @SuppressWarnings("unchecked") + record RepeatedTimestampAccessor(Function> listGetter, FieldName name) + implements TypedFieldAccessor { + @Override + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { + List values = (List) (List) listGetter.apply(msg); + if (values.isEmpty()) + return; + name.writeTo(jw, utf8); + jw.startArray(); + for (int i = 0; i < values.size(); i++) { + if (i > 0) + jw.writeComma(); + writeTimestampValue(jw, values.get(i), writer); + } + jw.endArray(); + } + } + + @SuppressWarnings("unchecked") + record RepeatedDurationAccessor(Function> listGetter, FieldName name) + implements TypedFieldAccessor { + @Override + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { + List values = (List) (List) listGetter.apply(msg); + if (values.isEmpty()) + return; + name.writeTo(jw, utf8); + jw.startArray(); + for (int i = 0; i < values.size(); i++) { + if (i > 0) + jw.writeComma(); + writeDurationValue(jw, values.get(i), writer); + } + jw.endArray(); + } + } + record RepeatedEnumAccessor(Function> valueListGetter, String[] names, EnumDescriptor enumType, boolean nullValue, FieldName name) implements TypedFieldAccessor { @Override @SuppressWarnings("unchecked") - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List values = (List) (List) valueListGetter.apply(msg); if (values.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startArray(); for (int i = 0; i < values.size(); i++) { if (i > 0) @@ -414,11 +490,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record MapAccessor(Function> entriesGetter, FieldDescriptor mapKeyDescriptor, FieldDescriptor mapValueDescriptor, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { List entries = entriesGetter.apply(msg); if (entries.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); FieldWriter.writeMap(jw, mapKeyDescriptor, mapValueDescriptor, entries, writer); } } @@ -426,11 +502,11 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record TypedMapAccessor(Function> mapGetter, FieldDescriptor keyFd, FieldDescriptor valueFd, FieldName name) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { java.util.Map map = mapGetter.apply(msg); if (map.isEmpty()) return; - name.writeTo(jw); + name.writeTo(jw, utf8); jw.startObject(); boolean first = true; for (var entry : map.entrySet()) { @@ -454,14 +530,14 @@ public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { record OneofAccessor(OneofDescriptor oneof, int[] fieldNumbers, TypedFieldAccessor[] accessors) implements TypedFieldAccessor { @Override - public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer) { + public void write(JSONWriter jw, Message msg, ProtobufMessageWriter writer, boolean utf8) { FieldDescriptor setField = msg.getOneofFieldDescriptor(oneof); if (setField == null) return; int number = setField.getNumber(); for (int i = 0; i < fieldNumbers.length; i++) { if (fieldNumbers[i] == number) { - accessors[i].write(jw, msg, writer); + accessors[i].write(jw, msg, writer, utf8); return; } } diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessorFactory.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessorFactory.java index 2f6057f..1f00ad6 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessorFactory.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedFieldAccessorFactory.java @@ -172,8 +172,7 @@ private static TypedFieldAccessor createPresenceAccessor(FieldDescriptor fd, Cla case MESSAGE -> { var getter = createObjectGetter(messageClass, getterName); var has = createPredicate(messageClass, hasName); - boolean wellKnown = WellKnownTypes.isWellKnownType(fd.getMessageType()); - yield new TypedFieldAccessor.PresenceMessageAccessor(castFunction(getter), has, wellKnown, name); + yield createPresenceMessageAccessor(fd, castFunction(getter), has, name); } }; } @@ -199,8 +198,7 @@ private static TypedFieldAccessor createRepeatedAccessor(FieldDescriptor fd, Cla case INT -> new TypedFieldAccessor.RepeatedIntAccessor(listGetter, isUnsigned32(fd), name); case LONG -> new TypedFieldAccessor.RepeatedLongAccessor(listGetter, isUnsigned64(fd), name); case STRING -> new TypedFieldAccessor.RepeatedStringAccessor(listGetter, name); - case MESSAGE -> new TypedFieldAccessor.RepeatedMessageAccessor(listGetter, - WellKnownTypes.isWellKnownType(fd.getMessageType()), name); + case MESSAGE -> createRepeatedMessageAccessor(fd, listGetter, name); default -> new TypedFieldAccessor.RepeatedAccessor(listGetter, fd, name); }; } @@ -337,6 +335,28 @@ private static boolean isUnsigned64(FieldDescriptor fd) { return type == FieldDescriptor.Type.UINT64 || type == FieldDescriptor.Type.FIXED64; } + private static TypedFieldAccessor createPresenceMessageAccessor(FieldDescriptor fd, + Function getter, Predicate has, FieldName name) { + String fullName = fd.getMessageType().getFullName(); + return switch (fullName) { + case "google.protobuf.Timestamp" -> new TypedFieldAccessor.PresenceTimestampAccessor(getter, has, name); + case "google.protobuf.Duration" -> new TypedFieldAccessor.PresenceDurationAccessor(getter, has, name); + default -> new TypedFieldAccessor.PresenceMessageAccessor(getter, has, + WellKnownTypes.isWellKnownType(fd.getMessageType()), name); + }; + } + + private static TypedFieldAccessor createRepeatedMessageAccessor(FieldDescriptor fd, + Function> listGetter, FieldName name) { + String fullName = fd.getMessageType().getFullName(); + return switch (fullName) { + case "google.protobuf.Timestamp" -> new TypedFieldAccessor.RepeatedTimestampAccessor(listGetter, name); + case "google.protobuf.Duration" -> new TypedFieldAccessor.RepeatedDurationAccessor(listGetter, name); + default -> new TypedFieldAccessor.RepeatedMessageAccessor(listGetter, + WellKnownTypes.isWellKnownType(fd.getMessageType()), name); + }; + } + /** * True if the field's enum type is {@code google.protobuf.NullValue} * (serializes as JSON null). diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedMessageSchema.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedMessageSchema.java index 25830df..24ab33e 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedMessageSchema.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/typed/TypedMessageSchema.java @@ -54,8 +54,9 @@ public static TypedMessageSchema forMessage(Descriptor descriptor, Class.get` with `Internal.IntList.getInt`. Primitive-list +source aesthetics are not evidence; specialization must remove measured +dispatch or allocation. Likewise, the baseline ratios identify where to +investigate, not which instruction is responsible. + +## Priority 2: profile and impose a compiler decision gate + +After each typed-plan change, rerun baseline and candidate on the same Java 25 +host in alternating order. Use JFR or async-profiler plus a narrowed +`-XX:+PrintInlining` run for simple UTF-8. Proceed to runtime class generation +only if all of the following hold: + +- typed runtime remains at least 15% slower than codegen on two relevant shapes + with non-overlapping confidence intervals; +- profiles attribute a material share to the heterogeneous accessor loop or + lambda calls rather than JSONWriter setup, escaping, number formatting, + Base64, or final output materialization; +- the expected steady-state saving repays first-use generation and C2 + compilation for the application's message lifetime; and +- generated-code size and class unloading can be bounded and tested. + +The current baseline meets the throughput-gap condition for simple UTF-8 and +map UTF-16, but it does **not** establish the causal/profile, cold-cost, or code +cache conditions. It is therefore insufficient evidence to make hidden classes +the default architecture. + +## Priority 3: prototype a whole-message encoder only if the gate passes + +Use `java.lang.classfile` (final in Java 25) to build an implementation of an +internal runtime-encoder interface, then define it with +`MethodHandles.Lookup.defineHiddenClass`. Generate one straight-line +`writeFields(JSONWriter, Message, ProtobufMessageWriter)` method with: + +- a single cast from `Message` to the concrete generated type; +- one `isUTF8()` decision, with either two complete method variants or one + hoisted boolean; +- direct `invokevirtual` calls to public `getXxx`, `hasXxx`, `getXxxList`, + `getXxxMap`, and `getXxxValue` methods; +- constants for encoded field names, enum tables, unsigned flags, and WKT + classification; +- inline default/presence checks and scalar JSONWriter calls; +- a oneof switch based on the generated `getXxxCase().getNumber()` method, + rather than `getOneofFieldDescriptor` plus an accessor scan; and +- specialized loops for all repeated and map key/value types. + +This would be runtime code generation, but not protobuf code generation: users +would not run a plugin and no generated source or service entry would be +needed. Public getters mean the hidden class does not need private access to +the application message class. Cache the plan by concrete class using +`ClassValue`; verify that the descriptor supplied at construction matches +instances at invocation. `ClassValue` allows class-loader unloading, unlike a +process-wide map whose values retain classes or descriptors. + +### Bootstrap and failure behavior + +Compilation is a cold-path cost and must happen once. A per-class state should +distinguish `BUILDING`, `READY`, and `FAILED`, permit recursive descriptors, +and publish the finished encoder safely. On any linkage, access, or unexpected +protoc-shape failure, retain the current typed schema as the fallback. Do not +silently fall all the way to descriptor reflection when typed access remains +valid. + +Hidden classes are intentionally undiscoverable and unload with their lookup +context, but the cache design still matters. The generated encoder must not +capture a `BuffJsonEncoder`, `TypeRegistry`, message instance, or mutable +`JSONWriter`. Those remain invocation parameters. A configurable maximum +compiled bytecode size should fall back for pathological schemas so a huge +message does not create an oversized C2 compilation unit. + +### Nested messages + +Start by calling `writer.writeMessage` for nested values; this already benefits +when the child plan is cached. Then test an inline cache in the runtime encoder: +cache the expected child class and its runtime encoder, and call it directly +between `startObject`/`endObject`. A polymorphic fallback is required because a +message-typed field can contain generated subclasses or unusual +implementations. Recursive types require lazy child resolution rather than +eager graph construction. + +## Priority 4: do not use `Unsafe` for protobuf field access + +Direct reads of generated protobuf fields look attractive, but are a poor +general foundation: + +- generated storage is not the public protobuf ABI and changes by protoc mode + and version; +- strings may be stored as either `String` or `ByteString` and getters perform + lazy conversion/caching; +- presence is encoded in implementation-specific bit fields; +- enums, oneofs, maps, repeated fields, extensions, and lite/full runtimes have + different representations; +- bypassing getters can observe representation rather than protobuf semantics; + and +- Java 25 warns on terminally deprecated `sun.misc.Unsafe` memory-access + methods, with stronger denial planned for later releases. + +More importantly, getter bodies are generally tiny and C2 can inline them at +monomorphic call sites. Unsafe field offsets do not solve the larger +heterogeneous-accessor dispatch problem. Even if the compiler decision gate +passes, use public getters in the prototype. Private field loads should remain +out of scope unless assembly proves a getter itself remains material, which is +unlikely. + +### The only defensible Unsafe experiment: bytes + +Bytes are different because the current path visibly calls +`ByteString.toByteArray()`. First seek or contribute a public fastjson2/protobuf +slice bridge. Only if bytes-heavy profiling proves the copy material and no +public API can remove it should an opt-in backend recognize protobuf's known +literal and bounded byte-string implementations, obtain backing array, offset, +and length, and call a Base64 method accepting a slice. Rope or unknown +implementations must fall back to a copy. This requires strict class/version +guards and differential tests; exposing a whole backing array to a method that +lacks offset/length is incorrect for bounded values. + +If an Unsafe prototype is run on Java 25, make the opt-in explicit and include +the VM policy in the benchmark record, for example: + +```text +--sun-misc-unsafe-memory-access=allow +``` + +Add `--add-opens` only if the chosen implementation actually reflects into a +non-open package; do not prescribe broad `--add-opens=...=ALL-UNNAMED` flags by +default. The library must probe support once, report why the requested backend +cannot activate, and otherwise fall back safely. Never require Unsafe for +correctness. + +## Secondary opportunities + +- **Writer reuse:** investigate an explicitly thread-confined encoder session + that reuses a resettable JSONWriter buffer. The public shared encoder must not + cache a writer, and retained peak buffers need a cap. Confirm fastjson2's + existing buffer cache before adding another layer. +- **Output sizing:** schema-derived pre-sizing may help large predictable + payloads, but scanning strings/repeated values twice will often lose. Prefer + adaptive retained capacity in a session if measurement supports it. +- **Streaming:** the `OutputStream` overload avoids retaining the final result, + but fastjson2 still buffers before `flushTo`. A genuinely chunked writer or + direct sink integration is more valuable for very large messages than + shaving getter calls. +- **Enum storage:** cap dense enum-name arrays and use a sparse secondary table. + This is primarily robustness, but prevents a sparse enum from failing runtime + plan construction and forcing the slow reflection path. +- **Cache lookup:** move typed metadata from descriptor-keyed global maps to a + class-keyed `ClassValue` front end, retaining descriptor validation. This + replaces a concurrent-map lookup in the hot entry path and improves + class-loader behavior. + +## Measurement and acceptance plan + +Do not merge a typed optimization, runtime compiler, or Unsafe backend on a +single favorable microbenchmark. On Java 25, compare codegen, current typed +runtime, the candidate typed runtime, and reflection. Add the runtime compiler +as a fifth path only if the decision gate is met. Cover: + +- simple scalars, all scalar types, complex nested messages, deep recursion, + maps, repeated primitives, bytes, WKT/`Any`, oneofs, and sparse enums; +- UTF-16 `String`, UTF-8 `byte[]`, and `OutputStream` output; +- warm steady state and cold first-use cost; +- monomorphic and mixed message classes at the same encode call site; +- one and multiple threads; and +- throughput, `gc.alloc.rate.norm`, generated-code size, compilation time, and + retained class metadata. + +Use at least two forks and enough warmup for C2 compilation; if hidden classes +are tested, separately report cold generation and first-encode latency. Save +JMH JSON and the exact `java -version`, CPU, commit, and VM arguments. Use JFR +or async-profiler to establish whether accessor dispatch, Base64 copying, +number formatting, escaping, or final materialization is actually dominant. +Inspect inlining with `-XX:+PrintInlining` on a narrowed benchmark; a faster +score without proof that the intended backend activated is not sufficient. + +Correctness gates are the full Maven suite and official ProtoJSON conformance +suite on the runtime path. Add differential tests against the current typed +encoder for randomized messages, negative zero, non-finite values, unknown and +negative enums, explicit presence, aliases, maps, oneofs, recursive messages, +all `ByteString` shapes, and mixed class loaders. Add a test-only diagnostic +that exposes the active backend so fallback cannot make a test pass unnoticed. + +## Recommended implementation sequence + +1. Keep the recorded Java 25 baseline, add backend-activation diagnostics, and + collect JFR/inlining evidence for simple UTF-8 and map UTF-16. +2. Keep the implemented direct typed Timestamp/Duration strategies and hoisted + UTF selection only while full-matrix controls remain clean. +3. Implement typed map strategies, then measure them against map, complex, and + simple controls. +4. Optimize oneof selection, missing repeated types, nested-plan lookup, and + UTF selection as separate changes. Retain only individually demonstrated + wins. +5. Rerun the complete matrix. If the runtime-compiler decision gate is not met, + stop: the maintenance and cold-start costs are unjustified. +6. If the gate is met, prototype a scalar-only Class-File API hidden encoder + behind an internal experimental switch. Measure cold and steady state before + adding presence, collections, nesting, or WKTs. +7. Profile bytes-heavy payloads. Prefer a public slice API; consider guarded + Unsafe access only if the copy is demonstrably important and explicitly + enabled. +8. Consider writer sessions or streaming only from large-message profiles. + +This revised sequence spends complexity in proportion to measured evidence, +preserves protobuf's public semantics, and keeps runtime compilation and unsafe +compatibility risk out of the default path until each clears an explicit gate. diff --git a/docs/performance-results/2026-09-22-java25-encode-decode-baseline.csv b/docs/performance-results/2026-09-22-java25-encode-decode-baseline.csv new file mode 100644 index 0000000..3d9052c --- /dev/null +++ b/docs/performance-results/2026-09-22-java25-encode-decode-baseline.csv @@ -0,0 +1,67 @@ +suite,benchmark,shape,generated,ops_per_second,error_99_9_percent,bytes_per_op,forks,warmup_iterations,measurement_iterations +DecodePathsBenchmark,string,simple,false,2231519.767,174264.773,461.941,2,3,5 +DecodePathsBenchmark,string,scalars,false,690084.997,43986.435,871.916,2,3,5 +DecodePathsBenchmark,string,complex,false,194364.072,19305.312,4059.780,2,3,5 +DecodePathsBenchmark,string,repeated,false,41863.937,3079.420,17688.711,2,3,5 +DecodePathsBenchmark,string,maps,false,33477.921,2449.192,28622.224,2,3,5 +DecodePathsBenchmark,string,timestamps,false,1611840.221,110065.615,640.004,2,3,5 +DecodePathsBenchmark,string,struct,false,232457.392,19496.861,6752.956,2,3,5 +DecodePathsBenchmark,string,deep,false,838448.284,60864.861,939.892,2,3,5 +DecodePathsBenchmark,string,any,false,297909.207,31314.315,1953.633,2,3,5 +DecodePathsBenchmark,string,simple,true,2601996.986,151595.382,394.061,2,3,5 +DecodePathsBenchmark,string,scalars,true,736740.253,47225.258,871.916,2,3,5 +DecodePathsBenchmark,string,complex,true,243348.780,15891.504,3794.523,2,3,5 +DecodePathsBenchmark,string,repeated,true,51523.927,2597.280,15266.605,2,3,5 +DecodePathsBenchmark,string,maps,true,35214.481,3617.744,25029.904,2,3,5 +DecodePathsBenchmark,string,timestamps,true,1932108.476,274383.826,584.004,2,3,5 +DecodePathsBenchmark,string,struct,true,224083.842,39682.783,6712.936,2,3,5 +DecodePathsBenchmark,string,deep,true,1223081.411,68730.794,701.295,2,3,5 +DecodePathsBenchmark,string,any,true,296669.913,14123.410,1945.634,2,3,5 +DecodePathsBenchmark,utf8,simple,false,2196824.451,188305.876,461.941,2,3,5 +DecodePathsBenchmark,utf8,scalars,false,671444.906,46638.349,871.917,2,3,5 +DecodePathsBenchmark,utf8,complex,false,196813.650,21668.319,4067.779,2,3,5 +DecodePathsBenchmark,utf8,repeated,false,39192.499,2203.203,17688.741,2,3,5 +DecodePathsBenchmark,utf8,maps,false,32919.696,3471.432,28622.023,2,3,5 +DecodePathsBenchmark,utf8,timestamps,false,1670284.522,82251.391,640.004,2,3,5 +DecodePathsBenchmark,utf8,struct,false,228029.904,15731.970,6752.951,2,3,5 +DecodePathsBenchmark,utf8,deep,false,838073.633,104923.743,939.891,2,3,5 +DecodePathsBenchmark,utf8,any,false,287503.795,31933.412,1953.632,2,3,5 +DecodePathsBenchmark,utf8,simple,true,2540896.730,292121.762,394.061,2,3,5 +DecodePathsBenchmark,utf8,scalars,true,712964.537,71234.056,871.916,2,3,5 +DecodePathsBenchmark,utf8,complex,true,231231.829,28628.365,3794.525,2,3,5 +DecodePathsBenchmark,utf8,repeated,true,48876.379,4991.126,15266.254,2,3,5 +DecodePathsBenchmark,utf8,maps,true,36361.341,1847.765,25029.600,2,3,5 +DecodePathsBenchmark,utf8,timestamps,true,2098529.720,112293.336,584.003,2,3,5 +DecodePathsBenchmark,utf8,struct,true,221508.474,16970.575,6712.864,2,3,5 +DecodePathsBenchmark,utf8,deep,true,1187632.361,79038.929,701.295,2,3,5 +DecodePathsBenchmark,utf8,any,true,288416.027,11761.196,1913.633,2,3,5 +EncodePathsBenchmark,complexCodegenUtf16,,,427190.314,75745.321,1449.931,2,3,5 +EncodePathsBenchmark,complexCodegenUtf8,,,489564.425,39686.645,1409.929,2,3,5 +EncodePathsBenchmark,complexReflectionUtf16,,,235901.716,13503.145,1385.943,2,3,5 +EncodePathsBenchmark,complexReflectionUtf8,,,264365.225,21935.363,1329.942,2,3,5 +EncodePathsBenchmark,complexTypedUtf16,,,437648.810,35176.559,1345.930,2,3,5 +EncodePathsBenchmark,complexTypedUtf8,,,436874.448,31337.896,1321.930,2,3,5 +EncodePathsBenchmark,mapCodegenUtf16,,,68378.734,5651.070,4673.579,2,3,5 +EncodePathsBenchmark,mapCodegenUtf8,,,66297.051,9585.522,4649.522,2,3,5 +EncodePathsBenchmark,mapReflectionUtf16,,,32059.695,1452.100,7682.230,2,3,5 +EncodePathsBenchmark,mapReflectionUtf8,,,31511.335,3873.132,7658.142,2,3,5 +EncodePathsBenchmark,mapTypedUtf16,,,47850.190,7059.597,4889.601,2,3,5 +EncodePathsBenchmark,mapTypedUtf8,,,56486.921,4028.095,4781.588,2,3,5 +EncodePathsBenchmark,simpleCodegenUtf16,,,5006721.432,636125.816,295.540,2,3,5 +EncodePathsBenchmark,simpleCodegenUtf8,,,6813099.882,717059.351,271.540,2,3,5 +EncodePathsBenchmark,simpleReflectionUtf16,,,2083463.875,186617.242,340.027,2,3,5 +EncodePathsBenchmark,simpleReflectionUtf8,,,2223684.982,168284.052,316.027,2,3,5 +EncodePathsBenchmark,simpleTypedUtf16,,,3948435.619,455147.914,295.541,2,3,5 +EncodePathsBenchmark,simpleTypedUtf8,,,4609666.252,340379.370,271.541,2,3,5 +EncodePathsBenchmark,structCodegenUtf16,,,341110.857,21662.571,959.107,2,3,5 +EncodePathsBenchmark,structCodegenUtf8,,,365010.124,29545.890,823.404,2,3,5 +EncodePathsBenchmark,structReflectionUtf16,,,375295.669,26179.616,735.704,2,3,5 +EncodePathsBenchmark,structReflectionUtf8,,,342459.192,23838.419,823.408,2,3,5 +EncodePathsBenchmark,structTypedUtf16,,,336504.419,20516.781,959.106,2,3,5 +EncodePathsBenchmark,structTypedUtf8,,,370748.152,57553.281,823.405,2,3,5 +EncodePathsBenchmark,timestampCodegenUtf16,,,2785797.800,209376.105,463.971,2,3,5 +EncodePathsBenchmark,timestampCodegenUtf8,,,3319643.297,330100.949,439.971,2,3,5 +EncodePathsBenchmark,timestampReflectionUtf16,,,1540090.799,114583.922,463.973,2,3,5 +EncodePathsBenchmark,timestampReflectionUtf8,,,1792010.215,107409.534,439.973,2,3,5 +EncodePathsBenchmark,timestampTypedUtf16,,,2219255.674,141854.782,463.972,2,3,5 +EncodePathsBenchmark,timestampTypedUtf8,,,2632325.368,356969.103,439.971,2,3,5 diff --git a/docs/performance-results/2026-09-22-java25-encode-decode-baseline.md b/docs/performance-results/2026-09-22-java25-encode-decode-baseline.md new file mode 100644 index 0000000..509fbd7 --- /dev/null +++ b/docs/performance-results/2026-09-22-java25-encode-decode-baseline.md @@ -0,0 +1,105 @@ +# Java 25 encode/decode baseline — 22 September 2026 + +This is the measured baseline for the runtime-encoding investigation at commit +`f3a0342ff3114b254879d192925b36b93a652d9b`. It covers all 30 cases in +`EncodePathsBenchmark` and all 36 parameter combinations in +`DecodePathsBenchmark`. The complete numeric data, including 99.9% confidence +errors and JMH configuration, is in +[`2026-09-22-java25-encode-decode-baseline.csv`](2026-09-22-java25-encode-decode-baseline.csv). + +## Environment and protocol + +- OpenJDK 25.0.2+10-69, 64-bit Server VM. +- Linux 6.18.44 x86-64. +- 3 vCPUs reported as Intel Xeon Platinum 8370C at 2.80 GHz; one thread per core. +- JMH 1.37, throughput mode, one benchmark thread, two forks. +- Three 1-second warmups and five 1-second measurements per fork. +- Fixed 256 MiB initial/maximum heap and the JMH GC profiler. +- Benchmark jar SHA-256: + `580a0f94a9cd2623e996ef189fedb9415a3a3eefab2bd1dfb46a432148386edd`. + +The exact command was: + +```bash +java -Xms256m -Xmx256m -jar buff-json-benchmarks/target/benchmarks.jar '.*(EncodePathsBenchmark|DecodePathsBenchmark).*' -wi 3 -i 5 -w 1s -r 1s -f 2 -t 1 -prof gc -rf json -rff /tmp/java25-encode-decode-baseline.json +``` + +These absolute numbers describe this container and commit, not a portable +performance promise. Future experiments should rerun baseline and candidate on +the same idle host/JVM. JMH errors below are 99.9% confidence intervals. + +## Encoding baseline + +Throughput is operations per second; allocation is normalized bytes per +operation. Runtime means the typed-accessor path. + +| Shape/output | Codegen ops/s | Runtime ops/s | Reflection ops/s | Runtime/codegen | Codegen B/op | Runtime B/op | Reflection B/op | +| --------------- | -----------------: | -----------------: | -----------------: | --------------: | -----------: | -----------: | --------------: | +| simple Utf16 | 5,006,721 ±636,126 | 3,948,436 ±455,148 | 2,083,464 ±186,617 | 78.9% | 295.5 | 295.5 | 340.0 | +| simple Utf8 | 6,813,100 ±717,059 | 4,609,666 ±340,379 | 2,223,685 ±168,284 | 67.7% | 271.5 | 271.5 | 316.0 | +| complex Utf16 | 427,190 ±75,745 | 437,649 ±35,177 | 235,902 ±13,503 | 102.4% | 1,449.9 | 1,345.9 | 1,385.9 | +| complex Utf8 | 489,564 ±39,687 | 436,874 ±31,338 | 264,365 ±21,935 | 89.2% | 1,409.9 | 1,321.9 | 1,329.9 | +| map Utf16 | 68,379 ±5,651 | 47,850 ±7,060 | 32,060 ±1,452 | 70.0% | 4,673.6 | 4,889.6 | 7,682.2 | +| map Utf8 | 66,297 ±9,586 | 56,487 ±4,028 | 31,511 ±3,873 | 85.2% | 4,649.5 | 4,781.6 | 7,658.1 | +| struct Utf16 | 341,111 ±21,663 | 336,504 ±20,517 | 375,296 ±26,180 | 98.6% | 959.1 | 959.1 | 735.7 | +| struct Utf8 | 365,010 ±29,546 | 370,748 ±57,553 | 342,459 ±23,838 | 101.6% | 823.4 | 823.4 | 823.4 | +| timestamp Utf16 | 2,785,798 ±209,376 | 2,219,256 ±141,855 | 1,540,091 ±114,584 | 79.7% | 464.0 | 464.0 | 464.0 | +| timestamp Utf8 | 3,319,643 ±330,101 | 2,632,325 ±356,969 | 1,792,010 ±107,410 | 79.3% | 440.0 | 440.0 | 440.0 | + +Observed on this run: + +- Typed runtime delivered 67.7–102.4% of codegen throughput across the ten + encode shape/output pairs. The complex UTF-16 and Struct results have + overlapping confidence intervals and must not be read as runtime wins. +- The largest measured runtime gap was simple UTF-8 (67.7% of codegen), followed + by map UTF-16 (70.0%). Use map UTF-16 first to evaluate typed map + specialization, and use simple UTF-8 to profile accessor dispatch before + deciding whether a whole-message runtime compiler is justified. +- Allocation is equal between codegen and runtime on simple, Struct, and + Timestamp, while complex and map differ. Getter dispatch alone cannot reduce + equal allocation; bytes and output materialization need separate + optimizations. + +## Decoding baseline + +“Runtime” uses `generated=false`; “generated” uses `generated=true`. + +| Shape/input | Runtime ops/s | Generated ops/s | Generated/runtime | Runtime B/op | Generated B/op | +| ----------------- | -----------------: | -----------------: | ----------------: | -----------: | -------------: | +| simple string | 2,231,520 ±174,265 | 2,601,997 ±151,595 | 116.6% | 461.9 | 394.1 | +| simple utf8 | 2,196,824 ±188,306 | 2,540,897 ±292,122 | 115.7% | 461.9 | 394.1 | +| scalars string | 690,085 ±43,986 | 736,740 ±47,225 | 106.8% | 871.9 | 871.9 | +| scalars utf8 | 671,445 ±46,638 | 712,965 ±71,234 | 106.2% | 871.9 | 871.9 | +| complex string | 194,364 ±19,305 | 243,349 ±15,892 | 125.2% | 4,059.8 | 3,794.5 | +| complex utf8 | 196,814 ±21,668 | 231,232 ±28,628 | 117.5% | 4,067.8 | 3,794.5 | +| repeated string | 41,864 ±3,079 | 51,524 ±2,597 | 123.1% | 17,688.7 | 15,266.6 | +| repeated utf8 | 39,192 ±2,203 | 48,876 ±4,991 | 124.7% | 17,688.7 | 15,266.3 | +| maps string | 33,478 ±2,449 | 35,214 ±3,618 | 105.2% | 28,622.2 | 25,029.9 | +| maps utf8 | 32,920 ±3,471 | 36,361 ±1,848 | 110.5% | 28,622.0 | 25,029.6 | +| timestamps string | 1,611,840 ±110,066 | 1,932,108 ±274,384 | 119.9% | 640.0 | 584.0 | +| timestamps utf8 | 1,670,285 ±82,251 | 2,098,530 ±112,293 | 125.6% | 640.0 | 584.0 | +| struct string | 232,457 ±19,497 | 224,084 ±39,683 | 96.4% | 6,753.0 | 6,712.9 | +| struct utf8 | 228,030 ±15,732 | 221,508 ±16,971 | 97.1% | 6,753.0 | 6,712.9 | +| deep string | 838,448 ±60,865 | 1,223,081 ±68,731 | 145.9% | 939.9 | 701.3 | +| deep utf8 | 838,074 ±104,924 | 1,187,632 ±79,039 | 141.7% | 939.9 | 701.3 | +| any string | 297,909 ±31,314 | 296,670 ±14,123 | 99.6% | 1,953.6 | 1,945.6 | +| any utf8 | 287,504 ±31,933 | 288,416 ±11,761 | 100.3% | 1,953.6 | 1,913.6 | + +Observed on this run: + +- Generated decoding was most beneficial for deep nesting (41.7–45.9% higher + mean throughput) and repeated-heavy messages (23.1–24.7% higher). +- Struct and Any means were effectively at parity; their confidence intervals + overlap. Work there is dominated by dynamic/WKT behavior that generated + ordinary-field setters do not remove. +- Generated decoding reduced allocation materially for complex, repeated, maps, + timestamps, and deep shapes. Scalars had identical normalized allocation. + +## How to use this baseline + +Use the CSV as a snapshot and sanity reference. For an optimization decision, +rerun the same command against both revisions on the same machine, alternate +order, and apply the repository performance-report thresholds. Do not compare +absolute values from a different CPU or a busy shared runner. Preserve the raw +JMH JSON for paired experiments so fork samples and confidence intervals remain +available.