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 exten
}
public void writeFields(JSONWriter jw, Message msg, ProtobufMessageWriter writer) {
+ boolean utf8 = jw.isUTF8();
for (var accessor : fields) {
- accessor.write(jw, msg, writer);
+ accessor.write(jw, msg, writer, utf8);
}
}
diff --git a/docs/java25-runtime-encode-performance.md b/docs/java25-runtime-encode-performance.md
new file mode 100644
index 0000000..d3ff90c
--- /dev/null
+++ b/docs/java25-runtime-encode-performance.md
@@ -0,0 +1,315 @@
+# Java 25 runtime encoder performance plan
+
+22 September 2026
+
+## Scope and conclusion
+
+This note considers protobuf-to-JSON **encoding without the buff-json protoc
+plugin**, on HotSpot 25 or newer. It does not propose making `DynamicMessage`
+depend on generated-class internals. The current typed-accessor path is already
+the right fallback architecture, but it cannot normally equal the protoc path:
+it executes a heterogeneous `TypedFieldAccessor.write` loop, invokes one or
+more lambda objects per field, and re-enters runtime dispatch for nested
+messages. The generated path instead presents C2 with one straight-line,
+type-specific method.
+
+The baseline changes the recommendation from the initial source-only analysis:
+**do not start by shipping a runtime bytecode compiler**. The typed path is
+already statistically indistinguishable from codegen for complex UTF-16 and
+Struct on this run, while its clearest deficits are shape-specific. First
+specialize maps, oneofs, missing repeated primitives, nested/WKT dispatch, and
+UTF selection in the existing typed plan, measuring each change. Then profile
+the remaining simple-scalar gap. A Java 25 Class-File API hidden encoder remains
+a bounded experiment only if dispatch is still proven dominant. Direct
+`Unsafe` field access is not recommended.
+
+The recommendations below combine source-path inspection with a Java 25
+baseline covering all 30 `EncodePathsBenchmark` cases and all 36
+`DecodePathsBenchmark` parameter combinations. The complete protocol, tables,
+and allocations are in the
+[baseline report](performance-results/2026-09-22-java25-encode-decode-baseline.md),
+with the underlying summary data in
+[CSV form](performance-results/2026-09-22-java25-encode-decode-baseline.csv).
+Optimization gains remain hypotheses until a same-host before/after JMH run
+validates them.
+
+## Where the runtime path still pays
+
+For every concrete message, `ProtobufMessageWriter.writeFields` obtains the
+descriptor and class, looks up `TypedMessageSchema`, and iterates a
+`TypedFieldAccessor[]`. Every array element reaches a different record
+implementation through the same interface call site. Each record then invokes
+a `Function`, `Predicate`, or primitive function produced by
+`LambdaMetafactory`. HotSpot can inline individual lambda targets, but the
+outer loop becomes polymorphic or megamorphic for realistic schemas. In
+contrast, a generated encoder has a distinct bytecode call site for each typed
+getter and each write operation.
+
+The remaining work is shape-dependent:
+
+1. **Small scalar messages:** interface/lambda dispatch and repeated
+ `JSONWriter.isUTF8()` checks are candidate costs, but profiling must separate
+ them from writer setup and result materialization.
+2. **Nested messages:** each child goes through `writeMessage`, descriptor and
+ schema-cache dispatch again.
+3. **Maps and repeated fields:** only repeated int, long, string, enum, and
+ message values have dedicated typed loops. Other primitive collections and
+ map values still enter descriptor-driven `FieldWriter` switches.
+4. **Bytes:** `ByteString.toByteArray()` allocates and copies before
+ `JSONWriter.writeBase64(byte[])`, even when the `ByteString` already owns a
+ suitable byte array.
+5. **Final output:** returning `String` or `byte[]` necessarily materializes the
+ result. For large payloads, JSON writing and this final copy dominate any
+ accessor optimization; the existing `OutputStream` overload should be
+ benchmarked separately.
+
+On the measured Java 25 baseline, typed runtime encoding delivered 67.7–102.4%
+of codegen throughput depending on shape and output. The clearest gaps were
+simple UTF-8 (67.7%) and map UTF-16 (70.0%); complex UTF-16 and both Struct
+means had overlapping confidence intervals. This supports targeting scalar
+dispatch and map specialization first, rather than assuming every message
+shape is accessor-bound.
+
+## Priority 1: specialize the existing typed plan
+
+These changes are lower risk, target the measured shape-specific gaps, and
+remain useful even if a runtime compiler is later justified:
+
+1. Give typed `Timestamp` and `Duration` fields direct seconds/nanos strategies,
+ matching the existing codegen helpers instead of entering generic
+ `WellKnownTypes.write` descriptor access. Timestamp runtime is consistently
+ about 79% of codegen in both outputs, making this a narrower and better
+ supported first experiment than whole-message compilation. Preserve generic
+ WKT fallback for custom implementations and cover repeated/map WKT values.
+2. Build typed map writer strategies once. Today `TypedMapAccessor` avoids
+ reflective map extraction but still dispatches key/value behavior by
+ descriptors for every entry. Separate string, boolean, signed integer, and
+ unsigned integer key writers, then value writers for every scalar, enum,
+ bytes, WKT, and ordinary message shape. Map UTF-16 is the most relevant
+ baseline gate; require improvement in UTF-8 too and no complex regression.
+3. Bind the generated oneof case getter once. A case-number accessor plus an
+ indexed member table can avoid descriptor lookup and linear matching on
+ every encoding.
+4. Add dedicated repeated accessors for `float`, `double`, `boolean`, and
+ `ByteString`. Use protobuf primitive-list APIs only when measurement shows a
+ benefit, and retain a normal `List` fallback for custom generated code.
+5. Cache ordinary nested runtime plans so children do not repeat descriptor and
+ concurrent-map lookup. Resolve recursively and lazily; preserve generated,
+ runtime, `DynamicMessage`, WKT, and polymorphic fallbacks.
+6. Hoist `isUTF8()` out of `FieldName.writeTo` by selecting UTF-8 or UTF-16 at
+ `TypedMessageSchema.writeFields`. Measure a boolean parameter before
+ duplicating accessor methods.
+7. Avoid abandoning an entire typed schema when one unusual accessor cannot be
+ bound. Use a per-field reflection accessor so ordinary fields retain the
+ fast path.
+
+Implementation status: items 1 and 6 are now implemented. Typed schemas hoist
+the writer encoding once per message, and singular/repeated concrete Timestamp
+and Duration fields use their direct primitive formatting helpers with a safe
+generic-WKT fallback. Typed map strategies are the next isolated change.
+
+Earlier repeated-int investigation found no convincing gain from merely
+replacing `List.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.