Serializer benchmarks for the 1.0.0 release candidate#

This report is the release-candidate performance evidence for 1.0.0 that #45 requires. It repeats the long serializer benchmarks of the 0.1.0 report on temporary Google Compute Engine VMs, with the same corpus, lanes, operations, fork and iteration settings, paired blocks, and practical tolerance, all defined in ADR-0004 . It adds the two DynamicMessage lanes that ADR-0004 describes. The results describe the recorded VMs and runtimes; they do not rank serializers universally and do not establish whole-job throughput or saved-state compatibility.

Reproduce#

The plan measured commit 9e7d175341fc0e418a97f199168d63c1a0f8cb33 with this specification:

{"runId": "issue45-rc-1", "mode": "run", "source": "9e7d175341fc0e418a97f199168d63c1a0f8cb33",
 "profiles": [{"protobuf": 4}, {"protobuf": 3}]}
mise x -- just gce-benchmark plan rc.json /tmp/issue45-gce/plan
mise x -- just gce-benchmark execute /tmp/issue45-gce/plan --approve
mise x -- just gce-benchmark collect /tmp/issue45-gce/plan /tmp/issue45-gce/results

Temporary GCE execution describes the commands, the load gate, maintenance handling, and cleanup. Each VM ran just benchmark-build 2.3.0 <major> and just benchmark-measure run 2.3.0 <major> on a checkout of the source commit, as each archive’s invocation.txt records. A rerun needs a new run ID and new output directories, and its VMs are not comparable causally with these.

Candidate and hosts#

The source commit is the merge of #173 , which set the version to 1.0.0-SNAPSHOT. The commits that follow it on main up to ed4db42 change no production source, POM, or dependency, so they do not change the measured library. A later change to production code or a dependency before the v1.0.0 tag requires a rerun or an explicit invalidation of this evidence.

just benchmark-build packages the library for each Protobuf profile, with -Pprotobuf<major> at Flink 2.3.0. The measured jars have SHA-256 cdc10744cc934f9d8c427aa09de066fce26ec6dcbbd8b2eca4bb732a925ff80e (Protobuf 4) and 0b47620f7758467cee99e9662d92d487ff960e567b9ce184fb405bb2a6c5fc5a (Protobuf 3). The release builds each artifact line once with Protobuf 4, at Flink 2.2.1 for the 2.x line, and ships that jar for both runtimes. A local rebuild of the source commit with Temurin 17.0.20 in the release configuration and in both benchmark configurations produced byte-identical class files and resources, which differed only in zip entry timestamps. The measured classes are therefore those of the release configuration, as Temurin 17 compiles them; the archives do not retain the VM jars, so this comparison covers the local rebuild only. #177 tracks measuring the release-configuration jar directly.

ItemValue
Plan, zoneissue45-rc-1, asia-northeast1-b
Machinec3-standard-8, Intel Sapphire Rapids (Xeon Platinum 8481C), 8 vCPUs, 32 GB
Image and kernelubuntu-2404-noble-amd64-v20261024 (Ubuntu 24.04.5 LTS), Linux 7.0.0-1014-gcp
JDKTemurin 17.0.21+9, -Xms1g -Xmx1g -XX:+UseG1GC
Flink2.3.0
Protobuf runtime and protoc4.36.2 and 3.25.9, one per VM
ComparatorsAvro 1.11.4, Thrift runtime and compiler 0.25.0, Chill 0.10.0, Kryo 5.6.2
HarnessJMH 1.37, corpus seed 30012026, 64 records per workload
ProfileVMMeasurement start (UTC)Measurement end (UTC)Accepted JMH results
Protobuf 4.36.2issue45-rc-1-pb42026-10-31 17:25:372026-10-31 23:14:102,005
Protobuf 3.25.9issue45-rc-1-pb32026-10-31 17:26:042026-10-31 23:14:212,005

Each profile holds 2,000 primary forks, ten lanes by five workloads by eight operations by five blocks, and the five deterministic supplements. Both VMs ran concurrently, one profile each, from the same image, machine type, and CPU platform. Their maintenance-event logs record only NONE, and both load gates passed before and after the build with a mean CPU idle of at least 99.95% and a minimum of at least 99.88%. Recomputing every native/Chill interval from measurements.csv reproduces parity.csv within floating-point rounding. The collection deleted both VMs and their disks, and a later cleanup check found no instance or disk labelled with the run.

Native and Chill time tolerance#

ProfileEstablishedInconclusiveMissed
Protobuf 3, all 40 cells3820
Protobuf 4, all 40 cells4000
Protobuf 3, 25 encoding, decoding, and construction cells2320
Protobuf 4, 25 encoding, decoding, and construction cells2500

No cell misses the 10% tolerance in either profile. All 15 copy cells per profile establish it, with upper limits of at most 0.114; their copy semantics differ from Chill’s, as the 0.1.0 report explains. The table shows the paired geometric native/Chill time ratio with its 95% interval for the encoding cells, next to the 0.1.0 point estimate; lower ratios favor native Protobuf. The archives retain every cell.

ProfileWorkloadOperation0.1.0 native/Chill1.0.0 native/Chill (95% interval)1.0.0 verdict
3scalarserialize9.3320.643 (0.623, 0.664)established
3scalardeserialize2.8830.603 (0.576, 0.630)established
3scalarbuildAndSerialize4.6710.761 (0.753, 0.770)established
3textserialize1.1070.519 (0.294, 0.915)established
3textdeserialize1.0430.812 (0.807, 0.816)established
3textbuildAndSerialize1.0120.627 (0.245, 1.604)inconclusive
3nestedserialize8.6060.736 (0.619, 0.876)established
3nesteddeserialize2.2420.705 (0.673, 0.739)established
3nestedbuildAndSerialize4.5540.868 (0.713, 1.057)established
3collectionsserialize5.4130.945 (0.931, 0.960)established
3collectionsdeserialize1.2670.915 (0.864, 0.969)established
3collectionsbuildAndSerialize4.6770.929 (0.879, 0.983)established
3largeserialize0.3580.338 (0.329, 0.347)established
3largedeserialize1.0910.543 (0.542, 0.544)established
3largebuildAndSerialize0.6310.649 (0.644, 0.654)established
4scalarserialize10.0970.611 (0.588, 0.634)established
4scalardeserialize3.0130.619 (0.602, 0.636)established
4scalarbuildAndSerialize4.6140.779 (0.769, 0.790)established
4textserialize2.5860.724 (0.711, 0.737)established
4textdeserialize1.0710.812 (0.798, 0.825)established
4textbuildAndSerialize2.0310.818 (0.792, 0.845)established
4nestedserialize9.2580.714 (0.707, 0.720)established
4nesteddeserialize2.1320.706 (0.686, 0.727)established
4nestedbuildAndSerialize4.6970.815 (0.698, 0.953)established
4collectionsserialize5.5740.880 (0.865, 0.894)established
4collectionsdeserialize1.2120.881 (0.830, 0.936)established
4collectionsbuildAndSerialize4.9070.886 (0.864, 0.908)established
4largeserialize0.3660.337 (0.334, 0.339)established
4largedeserialize1.0990.539 (0.534, 0.543)established
4largebuildAndSerialize0.6510.647 (0.641, 0.653)established

The two inconclusive Protobuf 3 cells have point estimates below the tolerance. Text buildAndSerialize, at 0.627 (0.245, 1.604), belongs to the Protobuf 3 text write cells whose JVM forks take one of two times: native text serialize forks take 639 to 642 or 1,467 to 1,469 ns/op, and Chill buildAndSerialize forks 1,496 or 3,193 to 3,359 ns/op. The standard deviation of the five log ratios is 0.39 to 0.76 for text serialize, serializeSameInstance, and buildAndSerialize, against at most 0.16 for every other cell; the sizing-allocation , generated-size , and large-read reports recorded the same spread, and #178 investigates it. Collections serializeSameInstance, at 0.945 (0.794, 1.125), crosses the tolerance because one native fork and one Chill fork each took about 20% longer than the other four of its lane.

Changes since the 0.1.0 baseline#

ADR-0004 does not compare a VM result causally with the Mac baseline, so this section describes differences without attributing them. Between the two runs the following changed:

  • Host: an Apple M1 Pro Mac with macOS 26.6.2 became a c3-standard-8 VM with Ubuntu 24.04.5.
  • JDK: Temurin 17.0.20+8 became 17.0.21+9.
  • Protobuf 4 profile: protobuf-java and protoc 4.33.6 became 4.36.2; the Protobuf 3 profile kept 3.25.9.
  • Thrift: runtime and compiler 0.23.0 became 0.25.0. Flink 2.3.0, Avro, Chill, Kryo, JMH, and the corpus are unchanged.
  • Library: the per-record buffer change of #42 , the sizing changes of #43 and #112 , the large-frame read of #113 , DynamicMessage support, and the 1.0.0 persisted-format restart.

The unchanged Chill comparator took 1.06 to 2.66 times as long on the VM as on the Mac for serialize and deserialize, so absolute times do not compare across the two runs. The native/Chill ratio divides out what the host change does equally to both lanes, but on Protobuf 4 it also contains the comparator’s runtime change. Of the 33 encoding cells that missed the tolerance in 0.1.0, 32 establish it now, and Protobuf 3 collections serializeSameInstance is inconclusive. The large-payload serialize and buildAndSerialize ratios stayed within 0.03 of their 0.1.0 values, while deserialize and deserializeReuse fell from 1.09–1.10 to 0.54–0.55; the large-read report measured that change of #113 against a baseline on the same VM.

Encoded sizes are unchanged for both Protobuf lanes in both profiles, for example 23.9 bytes for scalar, 498.1 for collections, and 65,563.9 for large native records. Native allocation, the mean of five forks, fell in every cell of this table:

WorkloadOperationProtobuf 3, 0.1.0 → 1.0.0 (B/op)Protobuf 4, 0.1.0 → 1.0.0 (B/op)
scalarserialize6,152.8 → 136.06,088.8 → 149.1
scalardeserialize4,519.8 → 375.84,495.8 → 351.8
scalarbuildAndSerialize6,861.6 → 744.06,752.8 → 661.1
textserialize6,296.8 → 136.06,210.4 → 1,957.1
textdeserialize8,271.8 → 4,127.88,247.8 → 4,103.8
textbuildAndSerialize7,994.4 → 1,768.07,901.6 → 3,577.9
nestedserialize6,305.6 → 136.06,222.4 → 245.1
nesteddeserialize4,679.8 → 535.84,655.8 → 511.8
nestedbuildAndSerialize6,987.2 → 744.06,947.2 → 757.1
collectionsserialize16,666.4 → 148.817,050.4 → 533.1
collectionsdeserialize7,883.0 → 3,739.07,859.0 → 3,791.8
collectionsbuildAndSerialize19,488.8 → 1,992.019,413.6 → 2,029.1
largeserialize6,208.8 → 4,506.16,144.8 → 4,519.3
largedeserialize132,080.0 → 65,988.1132,056.0 → 65,964.1
largebuildAndSerialize72,461.6 → 70,695.072,410.4 → 70,708.1

The buffer-allocation and sizing-allocation reports measured the main reductions against baselines on the same VM. The remaining Protobuf 4 write allocation grows with the number of string fields: text serialize allocates 1,957.1 B/op on Protobuf 4 against 136.0 on Protobuf 3. Chill and DynamicMessage carry the same increase on Protobuf 4, and Chill’s own text serialize allocation rose from 1,814.5 B/op with 4.33.6 in 0.1.0 to 3,642.0 B/op with 4.36.2, so it comes from the protobuf-java 4.36.2 runtime rather than from this library.

Cross-format encoding costs#

Each entry is the arithmetic mean of five fork scores for ordinary corpus-cycling serialize; time is ns/op, allocation is B/op from the GC profiler, and encoded bytes are the corpus mean including serializer framing. The two values in each cell are profile 3 / profile 4, measured on different VMs of one plan. These are descriptive means; the paired intervals above and the per-fork data carry the uncertainty.

WorkloadLaneTime (ns/op), 3 / 4Allocation (B/op), 3 / 4Encoded bytes, 3 / 4
scalarPOJO111.5 / 111.423.6 / 23.646.9 / 46.9
scalarTUPLE71.8 / 71.90.0 / 0.035.2 / 35.2
scalarAVRO_SPECIFIC120.3 / 120.9132.8 / 132.839.9 / 39.9
scalarAVRO_GENERIC139.5 / 137.6109.1 / 109.139.9 / 39.9
scalarPROTOBUF_NATIVE63.8 / 54.5136.0 / 149.123.9 / 23.9
scalarPROTOBUF_DYNAMIC94.6 / 90.340.0 / 53.123.9 / 23.9
scalarPROTOBUF_DYNAMIC_FOREIGN93.9 / 89.340.0 / 53.123.9 / 23.9
scalarPROTOBUF_CHILL99.2 / 89.3174.5 / 187.622.9 / 22.9
scalarTHRIFT_CHILL1,040.5 / 1,039.7157.0 / 157.081.8 / 81.8
scalarKRYO256.8 / 255.00.0 / 0.027.2 / 27.2
textPOJO1,398.3 / 1,383.023.6 / 23.61681.8 / 1681.8
textTUPLE1,356.1 / 1,271.30.0 / 0.01670.1 / 1670.1
textAVRO_SPECIFIC834.8 / 833.01,924.8 / 1,924.81673.7 / 1673.7
textAVRO_GENERIC853.1 / 851.91,901.1 / 1,901.11673.7 / 1673.7
textPROTOBUF_NATIVE971.6 / 808.1136.0 / 1,957.11663.8 / 1663.8
textPROTOBUF_DYNAMIC724.2 / 854.640.0 / 1,861.11663.8 / 1663.8
textPROTOBUF_DYNAMIC_FOREIGN728.4 / 854.940.0 / 1,861.11663.8 / 1663.8
textPROTOBUF_CHILL1,721.8 / 1,116.31,814.5 / 3,642.01663.8 / 1663.8
textTHRIFT_CHILL1,865.6 / 1,864.93,586.0 / 3,586.01716.7 / 1716.7
textKRYO1,022.0 / 1,020.90.0 / 0.01664.0 / 1664.0
nestedPOJO133.1 / 133.434.9 / 34.964.8 / 64.8
nestedTUPLE95.9 / 98.30.0 / 0.053.1 / 53.1
nestedAVRO_SPECIFIC146.5 / 152.3224.0 / 224.057.7 / 57.7
nestedAVRO_GENERIC163.1 / 165.9189.1 / 189.157.7 / 57.7
nestedPROTOBUF_NATIVE89.9 / 88.2136.0 / 245.146.4 / 46.4
nestedPROTOBUF_DYNAMIC154.7 / 141.040.0 / 149.146.4 / 46.4
nestedPROTOBUF_DYNAMIC_FOREIGN144.8 / 140.740.0 / 149.146.4 / 46.4
nestedPROTOBUF_CHILL121.1 / 123.7198.5 / 307.645.4 / 45.4
nestedTHRIFT_CHILL1,050.7 / 1,053.0258.0 / 258.099.7 / 99.7
nestedKRYO298.7 / 303.70.0 / 0.046.9 / 46.9
collectionsPOJO384.8 / 388.623.6 / 23.6420.9 / 420.9
collectionsTUPLE309.2 / 309.90.0 / 0.0409.2 / 409.2
collectionsAVRO_SPECIFIC544.1 / 548.6516.8 / 516.8447.9 / 447.9
collectionsAVRO_GENERIC561.7 / 587.6493.1 / 518.7447.9 / 447.9
collectionsPROTOBUF_NATIVE861.4 / 777.3148.8 / 533.1498.1 / 498.1
collectionsPROTOBUF_DYNAMIC1,541.2 / 1,350.8136.0 / 437.1498.1 / 498.1
collectionsPROTOBUF_DYNAMIC_FOREIGN1,544.3 / 1,351.8136.0 / 437.1498.1 / 498.1
collectionsPROTOBUF_CHILL911.5 / 883.5675.7 / 1,047.3498.1 / 498.1
collectionsTHRIFT_CHILL2,676.4 / 2,678.01,045.0 / 1,061.0488.8 / 488.8
collectionsKRYO1,198.6 / 1,206.30.0 / 0.0433.0 / 433.0
largePOJO2,763.5 / 2,774.523.6 / 23.665582.9 / 65582.9
largeTUPLE2,673.1 / 2,696.80.0 / 0.065571.2 / 65571.2
largeAVRO_SPECIFIC2,884.8 / 2,847.6132.8 / 132.865575.9 / 65575.9
largeAVRO_GENERIC2,908.3 / 2,892.2109.1 / 109.165575.9 / 65575.9
largePROTOBUF_NATIVE3,583.1 / 3,632.84,506.1 / 4,519.365563.9 / 65563.9
largePROTOBUF_DYNAMIC3,690.3 / 3,644.84,184.0 / 4,197.165563.9 / 65563.9
largePROTOBUF_DYNAMIC_FOREIGN3,663.1 / 3,686.54,184.0 / 4,197.165563.9 / 65563.9
largePROTOBUF_CHILL10,602.4 / 10,792.065,716.3 / 65,729.465564.9 / 65564.9
largeTHRIFT_CHILL13,600.7 / 13,778.665,693.1 / 65,693.165619.8 / 65619.8
largeKRYO3,302.6 / 3,314.20.0 / 0.065565.2 / 65565.2

Native scalar serialization is the fastest of all lanes, at 54.5 to 63.8 ns/op. Collections have the largest native cost relative to the fixed-layout lanes: native serialize takes 777 to 861 ns/op, 0.88 to 0.95 times Chill’s time but 2.5 to 2.8 times Tuple’s and 2.0 to 2.2 times POJO’s. Protobuf writes each of the 16 map entries as a length-delimited message and each long as a varint, while Flink’s POJO and Tuple serializers write longs as fixed eight-byte values and no per-entry length; this report does not measure how much each difference contributes. Native large serialization takes 0.34 times Chill’s time, but 1.08 to 1.10 times Kryo’s, and allocates about 4.5 KB per record for the stream encoder buffer that #176 addresses. Generated-message serialize allocates 96 B/op more than DynamicMessage serialize of the same content in the scalar, text, and nested workloads of both profiles; #175 investigates that fixed allocation.

DynamicMessage lanes#

PROTOBUF_DYNAMIC values carry the serializer’s own descriptors, taken from a value it decoded, and PROTOBUF_DYNAMIC_FOREIGN values carry an equal-content descriptor from another instance, which the serializer accepts through its cache of accepted descriptor pairs. Both are measured in the same blocks as the generated and Chill lanes, and ADR-0004 gives their ratios as descriptive results without a tolerance or verdict. The table shows the encoding operations; dynamic.csv in each archive holds all 40 cells.

ProfileWorkloadOperationDynamic/generated (95% interval)Foreign/own (95% interval)
3scalarserialize1.484 (1.468, 1.500)0.992 (0.959, 1.027)
3scalardeserialize6.319 (6.124, 6.521)1.000 (0.988, 1.011)
3scalarbuildAndSerialize2.960 (2.932, 2.988)1.022 (1.009, 1.035)
3textserialize0.811 (0.465, 1.415)1.006 (0.983, 1.029)
3textdeserialize1.568 (1.525, 1.614)0.977 (0.950, 1.005)
3textbuildAndSerialize1.063 (0.593, 1.908)1.007 (0.982, 1.033)
3nestedserialize1.728 (1.336, 2.235)0.937 (0.773, 1.136)
3nesteddeserialize5.841 (5.688, 5.998)1.000 (0.986, 1.013)
3nestedbuildAndSerialize3.161 (2.576, 3.879)0.983 (0.913, 1.059)
3collectionsserialize1.789 (1.754, 1.825)1.002 (0.968, 1.037)
3collectionsdeserialize5.338 (5.245, 5.433)1.009 (0.991, 1.027)
3collectionsbuildAndSerialize5.659 (5.163, 6.203)1.001 (0.988, 1.014)
3largeserialize1.030 (0.999, 1.062)0.993 (0.963, 1.023)
3largedeserialize1.111 (1.107, 1.115)0.998 (0.991, 1.005)
3largebuildAndSerialize1.048 (1.045, 1.051)1.001 (0.993, 1.009)
4scalarserialize1.655 (1.596, 1.716)0.989 (0.927, 1.055)
4scalardeserialize6.344 (6.276, 6.413)0.994 (0.984, 1.005)
4scalarbuildAndSerialize2.918 (2.887, 2.949)1.008 (1.000, 1.017)
4textserialize1.058 (1.050, 1.066)1.000 (0.988, 1.013)
4textdeserialize1.622 (1.600, 1.644)0.994 (0.972, 1.016)
4textbuildAndSerialize1.430 (1.401, 1.460)1.008 (0.993, 1.024)
4nestedserialize1.598 (1.576, 1.620)0.998 (0.988, 1.007)
4nesteddeserialize6.108 (5.853, 6.375)1.002 (0.985, 1.020)
4nestedbuildAndSerialize3.197 (2.751, 3.716)1.006 (0.994, 1.018)
4collectionsserialize1.738 (1.705, 1.771)1.001 (0.982, 1.020)
4collectionsdeserialize5.760 (5.008, 6.626)0.999 (0.817, 1.220)
4collectionsbuildAndSerialize5.582 (5.423, 5.747)1.013 (0.990, 1.036)
4largeserialize1.003 (0.976, 1.031)1.011 (0.992, 1.031)
4largedeserialize1.114 (1.096, 1.132)1.002 (0.987, 1.018)
4largebuildAndSerialize1.050 (1.037, 1.063)1.003 (0.994, 1.011)

Decoding a DynamicMessage takes 5.3 to 6.3 times as long as decoding the generated message for the scalar, nested, and collections records, 1.6 times for text, and 1.1 times for large. The serializer decodes both through the parser of the value’s type, so the difference is the cost of protobuf-java’s DynamicMessage parsing; DynamicMessage collections decoding also allocates 9.5 to 12.6 KB per record against 3.7 to 3.8 KB. Writing takes 1.5 to 1.8 times as long for the scalar, nested, and collections records and 0.81 to 1.06 times as long for text and large, where the Protobuf 3 text interval, 0.465 to 1.415, is wide. buildAndSerialize also includes building the DynamicMessage value with its builder, which is the benchmark’s conversion, not serializer work.

The foreign/own point estimates lie between 0.91 and 1.05 across all 80 cells. Eight of their intervals exclude 1, by at most 3%, and four of those are copy or stream-copy cells, which run the same code for both lanes; such differences are therefore within the variation between adjacent lanes. Values with descriptors from another instance thus show no measurable cost from the write-acceptance cache beyond that variation.

Remaining gaps and release decision#

  • Collections encoding remains the narrowest native margin over Chill, 0.88 to 0.95, and costs several times the fixed-layout lanes.
  • Scalar and nested encoding are now faster than Chill in every operation, against 2.1 to 10.1 times Chill’s time in 0.1.0.
  • On Protobuf 4.36.2, every Protobuf lane allocates an additional amount per string field when writing.
  • Protobuf 3 text write cells stay bimodal across forks, which leaves one of them inconclusive (#178 ).
  • DynamicMessage decoding costs 5.3 to 6.3 times generated decoding for small records, which applications choosing DynamicMessage pay to protobuf-java’s parser.
  • #175 and #176 track the two library allocations above, and #177 the measured jar, all for 1.1.0.

No cell misses the tolerance, and the only encoding ratio above its 0.1.0 estimate, Protobuf 3 large buildAndSerialize, rose from 0.631 to 0.649 across the host and runtime changes listed above. The measurements therefore show no material regression and raise no performance objection to releasing this candidate as 1.0.0. None of the follow-ups requires an API, state-format, or default change.

Retained evidence#

The retained evidence holds both complete result directories with their GCE run records, checksums, and inventories.