Serializer implementation performance check#

This local comparison measures the getter-name removal, metadata cache, and sizing refactor against commit f6a6e33d272f74da80f82e9d77718399cda9702e. The candidate is the implementation accompanying this report. It is a small implementation comparison, not a ranking against other formats or an end-to-end Flink throughput result. The maintained cross-format harness and its v0.1.0 baseline work are described in the serializer benchmark report , tracked in #30 .

Conditions#

Measurements ran on 2026-09-11 using an Apple M1 Pro with 32 GiB RAM, macOS 26.6.2, and Temurin 17.0.20+8. Both variants used Flink 2.3.0, JMH 1.37, G1 GC, a fixed 256 MiB heap, and one benchmark thread. Protobuf runtime and generated fixtures were paired at 3.25.8 and 4.33.6 in separate classpaths. Each case used two JVM forks, three 500 ms warmup iterations, five 500 ms measurement iterations, average time mode, and the JMH GC profiler. Final comparisons ran sequentially without concurrent builds or another benchmark. The table reports the JMH mean and its 99.9% confidence interval half-width; allocated bytes are rounded per operation. These short runs on a development machine cannot establish small performance differences reliably.

The temporary harness compiles the baseline or candidate production sources with the corresponding generated test fixtures and JMH annotation processor. Its timing command is equivalent to:

java -cp "$BENCHMARK_CLASSPATH" org.openjdk.jmh.Main SerializerBenchmark \
  -f 2 -wi 3 -i 5 -w 500ms -r 500ms -bm avgt -tu ns -t 1 \
  -jvmArgs '-Xms256m -Xmx256m -XX:+UseG1GC' -prof gc -rf json -rff results.json

This implementation comparison’s harness and raw JSON remain local investigation artifacts. The separate maintained benchmark provides independently runnable cross-format comparisons. No benchmark dependencies enter this library’s production artifact.

Workloads#

All serialization cases reuse a single immutable generated value and a serializer with default settings. The timed operation clears a preallocated 128 KiB DataOutputSerializer, calls serialize(value, output), and returns the resulting framed length to JMH. Setup checks that the frame length equals value.toByteArray().length + 4 before measurement. Inputs, generated accessors, memoized runtime sizes, and buffers are therefore warm; fresh-record construction and cold class initialization are not measured.

CaseInput and timed operation
constructReturn a new ProtobufTypeSerializer<>(ScalarMessage.class); the candidate’s class metadata cache is already populated during warmup
scalarScalarMessage with int32_value = 42
utf8The scalar plus string_value = "ascii é 中 😀".repeat(16)
rawLegacyMessage.parseFrom(new byte[] {10, 2, (byte) 255, (byte) 254})
nested_rawStart with raw; eight times wrap the previous value in groupvalue.child of a new raw message, yielding nine ambiguous strings and parser depth 16
large_bytesThe scalar plus a 65,536-byte zero-filled bytes_value

Results#

Protobuf majorCaseBefore ns/opAfter ns/opBefore B/opAfter B/op
3construct5718.2 ± 226.56.1 ± 0.61918064
3scalar1065.1 ± 52.51016.1 ± 22.158085824
3utf81463.7 ± 205.51360.8 ± 17.058485848
3raw584.7 ± 122.0528.4 ± 10.753845624
3nested_raw3098.4 ± 231.93160.4 ± 69.51628815968
3large_bytes2251.4 ± 32.32320.8 ± 71.158645880
4construct6798.6 ± 916.75.9 ± 0.11897264
4scalar1198.8 ± 143.41090.2 ± 25.957445760
4utf81867.6 ± 706.91477.0 ± 18.558005816
4raw670.9 ± 15.3612.5 ± 51.853125520
4nested_raw3607.9 ± 57.33867.4 ± 418.01619216312
4large_bytes2473.1 ± 307.52477.4 ± 60.958005800

Caching validated metadata removes repeated reflective default lookup, parser validation, and descriptor normalization from warm serializer construction. That construction result does not imply a comparable per-record or whole-job speedup. In the measured candidate, ordinary serialization still traversed fields and allocated sizing metadata, but it did not perform this initialization work on each record; the sizing-allocation report measures the later traversal change.

The raw-string path replaces generated getter-name guesses with one bounded counting pass through public APIs. It can allocate more for a small raw message, while removing per-occurrence reflection/cache-key work in nested messages. The candidate counts the root once after validating the entire graph, so nested ambiguous strings do not cause one counting pass per message node. The implementation does not retain records or their size summaries between calls.

This check does not cover deserialization, copying, cold classloading, fresh-record allocation, other Flink/JDK versions, or all schema shapes. Those results must not be inferred from this table; compatibility remains covered separately by the project’s verification matrix. No timing threshold is added to shared-runner CI.