Coded-stream buffer allocation in the native serializer#

This report measures the per-record coded-stream buffers investigated in #42 . It separates the output and input buffers with controlled variants of one source tree and compares each with the unchanged serializer on the same VM. The measurement contract is ADR-0004 ; the comparison does not establish whole-job throughput or state compatibility.

Change under test#

Before the change, every serialize call wrapped the target view in CodedOutputStream.newInstance(OutputStream), and every deserialize call wrapped a bounded frame stream in CodedInputStream.newInstance(InputStream). In the pinned Protobuf 3.25.9 and 4.33.6 runtimes, both factories allocate a 4,096-byte buffer for each call.

Each serializer instance now retains one lazily allocated 4 KiB array, which stream copy already used. A payload of at most 4 KiB is encoded into that array by an array encoder and then written with its length prefix. On reading, such a payload is read completely into the array and parsed by an array decoder without aliasing; decoded bytes and strings are copies. Larger payloads keep the previous streaming paths and their per-call buffers. Framing, deterministic writing, snapshots, settings, and public defaults are unchanged; ADR-0001 records the amended buffering rule.

The merged serializer differs from the measured tree only after a generated writer disagrees with the calculated size, where it now reports an overrun of a small payload as such rather than as short output, and in Javadoc and comments. Successful writes and reads perform the same operations as the measured tree.

Small frames gain one behavioral difference: a generated writer whose output disagrees with the calculated size now leaves the target untouched, because the frame is completed in the array first. Size and depth limits, overflow checks, malformed-input failures, adjacent-frame boundaries, and thread confinement keep their existing tests; ProtobufTypeSerializerTest adds boundary sizes around 4 KiB, fresh and repeatedly encoded values, no aliasing after the array is overwritten, concurrent duplicates, and both inconsistent-writer paths.

Method#

The measurement used the temporary GCE procedure with plan issue42-ablation-2 in project flink-gcp. Each Protobuf profile ran on its own c3-standard-8 VM in asia-northeast1-b (Intel Xeon Platinum 8481C, Sapphire Rapids, 8 vCPUs, 32 GiB) from image ubuntu-2404-noble-amd64-v20260918, kernel 7.0.0-1011-gcp, with Temurin 17.0.20.1, Flink 2.3.0, and Protobuf/protoc 3.25.9 or 4.33.6. The plan requested standard provisioning, and every VM manifest records TERMINATE host maintenance; every result recorded NONE as the only maintenance event and passed the load gate before its build and before measurement. Both VMs reported the same machine type, CPU platform, image, kernel, and JDK.

just benchmark-pair ran the ADR-0004 long-run protocol for the native and Chill Protobuf lanes only: all five workloads and eight operations, five independent blocks with alternating native/Chill order, five one-second warmups and measurements per fork, one thread, -Xms1g -Xmx1g -XX:+UseG1GC, and the JMH GC profiler. Correctness gates for every lane ran before timing.

The source commit was 2b9d952880df15d45ad6e85178f0fc647093924d, which adds the candidate serializer and the paired mode to the GCE tooling. Variants are patches to that commit:

VariantSerializer under test
baselineThe previous serializer and its tests
outputCandidate writing; previous streaming read path
inputCandidate reading; previous streaming write path
candidateThe change described above

Protobuf 4 ran baseline, candidate, output, and input sequentially on one VM; Protobuf 3 ran baseline and candidate on the other. Each run took about 70 minutes, between 09:55 and 15:06 UTC on 2026-10-11. The first attempt, issue42-ablation-1, failed to compile its baseline tests and produced no measurements.

For each variant, the native/Chill tolerance verdict follows ADR-0004. Comparisons with the baseline use the Chill lane as a per-block control: the ratio of a variant’s geometric-mean native/Chill time ratio to the baseline’s, with a pooled two-sample Student-t interval on the five log ratios of each run (8 degrees of freedom). Below 1 favors the variant. Allocation is the five-fork mean of JMH’s normalized allocation, in bytes per operation. These intervals are per cell, without a simultaneous claim across the 40 cells of each profile or the 80 in total.

Results#

Protobuf 4: separating output and input buffers#

Each cell is native time in ns/op and allocation in B/op, as five-fork means. The last two columns describe the candidate: its native/Chill interval and verdict, and its time relative to the baseline.

WorkloadOperationBaselineOutputInputCandidateCandidate native/ChillCandidate/baseline
scalarserialize1345 / 6113685 / 19881338 / 6142684 / 19887.023 (6.923, 7.124) missed0.509 (0.494, 0.524)
textserialize2403 / 62731793 / 21192411 / 62731771 / 21292.091 (1.858, 2.353) missed0.733 (0.638, 0.842)
nestedserialize1489 / 6251844 / 21071476 / 6261839 / 20936.888 (6.741, 7.038) missed0.564 (0.552, 0.577)
collectionsserialize5953 / 170505380 / 131435912 / 168205393 / 131506.005 (5.823, 6.192) missed0.903 (0.851, 0.958)
largeserialize4183 / 62174172 / 62174169 / 61984181 / 61980.393 (0.389, 0.397) established0.995 (0.984, 1.007)
scalardeserialize741 / 4496723 / 4496111 / 352109 / 3520.646 (0.627, 0.666) established0.147 (0.139, 0.157)
textdeserialize1844 / 82481848 / 82481224 / 41041221 / 41040.810 (0.797, 0.822) established0.666 (0.655, 0.677)
nesteddeserialize746 / 4656750 / 4656170 / 512168 / 5120.715 (0.697, 0.733) established0.227 (0.220, 0.234)
collectionsdeserialize2024 / 77822022 / 78591477 / 37921484 / 37920.903 (0.883, 0.923) established0.737 (0.714, 0.761)
largedeserialize20868 / 13205620769 / 13205620884 / 13205620905 / 1320561.272 (1.267, 1.276) missed1.002 (0.997, 1.006)
scalarserializeSameInstance1157 / 6056581 / 18641145 / 6040600 / 18567.025 (6.407, 7.703) missed0.515 (0.474, 0.560)
collectionsserializeSameInstance5573 / 171604876 / 130225451 / 166994936 / 127986.105 (5.811, 6.415) missed0.884 (0.838, 0.932)
scalarbuildAndSerialize1610 / 6849995 / 27051619 / 68671002 / 27133.263 (3.123, 3.409) missed0.626 (0.602, 0.651)
collectionsbuildAndSerialize7905 / 194147269 / 152707813 / 194147293 / 152813.977 (3.848, 4.111) missed0.912 (0.881, 0.943)

The output variant removes 3.9 to 4.2 KB per write, and the input variant 3.9 to 4.2 KB per read, on every workload whose payload fits in 4 KiB; collections cells are at the low end. Neither variant moves the other direction’s allocation by more than 461 B/op. Their time intervals for the other direction contain 1 in 23 of 25 cells. The exceptions are the input variant’s collections buildAndSerialize, at 0.971 (0.946, 0.996), and large serializeSameInstance, at 1.013 (1.000, 1.025); both measure the unchanged write path. The candidate’s effect on each operation matches the variant that changes that direction.

The write-side reduction is the same in each size-cache state. serialize cycles over previously encoded records, serializeSameInstance repeats one encoded record, and buildAndSerialize writes a fresh record whose memoized sizes the generated writer computes during that write. All three lose 3.9 to 4.4 KB/op, so the removed allocation is serializer buffering, not generated size caching.

Payloads above 4 KiB take the unchanged streaming paths. Every candidate and output large interval relative to the baseline contains 1 on Protobuf 4; Protobuf 4 input serializeSameInstance and Protobuf 3 candidate deserialize lie just above 1 and are discussed below. Large deserialization still allocates about 132 KB/op, about twice its 64 KiB payload, through runtime byte copying that this change does not address.

Protobuf 3: baseline and candidate#

WorkloadOperationBaselineCandidateCandidate native/ChillCandidate/baseline
scalarserialize1325 / 6196665 / 20716.861 (6.562, 7.172) missed0.506 (0.486, 0.526)
textserialize2417 / 63371798 / 21931.051 (1.016, 1.087) established0.652 (0.477, 0.891)
nestedserialize1476 / 6339806 / 21866.653 (6.474, 6.838) missed0.547 (0.533, 0.562)
collectionsserialize6014 / 171405347 / 129775.540 (5.001, 6.138) missed0.847 (0.777, 0.924)
largeserialize4148 / 62814125 / 62810.386 (0.381, 0.391) established0.992 (0.972, 1.013)
scalardeserialize735 / 4520111 / 3760.665 (0.642, 0.689) established0.152 (0.147, 0.157)
textdeserialize1890 / 82721266 / 41280.808 (0.801, 0.815) established0.671 (0.664, 0.678)
nesteddeserialize767 / 4680174 / 5360.741 (0.717, 0.766) established0.230 (0.222, 0.239)
collectionsdeserialize2033 / 78831439 / 37390.980 (0.905, 1.060) established0.739 (0.690, 0.791)
largedeserialize20876 / 13208020870 / 1320801.271 (1.268, 1.275) missed1.004 (1.000, 1.008)

Tolerance verdicts#

ProfileVariantEstablishedInconclusiveMissed
Protobuf 4, 40 cellsbaseline18022
Protobuf 4, 40 cellscandidate26014
Protobuf 3, 40 cellsbaseline18121
Protobuf 3, 40 cellscandidate27211

Every verdict change is from missed to established or inconclusive: deserialize and deserializeReuse for the four small workloads in both profiles, Protobuf 3 text serialize (established), and Protobuf 3 text serializeSameInstance (inconclusive). The candidate still misses the tolerance for scalar, nested, and collections writes in both profiles; scalar and nested serialize and serializeSameInstance remain at 6.2 to 7.0 times Chill’s time and allocate 1.9 to 2.2 KB/op. Protobuf 3 text writes are established for serialize and inconclusive for the other two write operations; Protobuf 4 text writes miss. The 0.1.0 diagnostics sampled the remaining allocation sites in the safe-sizing traversal, which #43 owns; this report does not attribute the remaining time to it. The sizing-allocation report measures that traversal separately. Chill’s Protobuf 4 text writes are about twice as fast as its Protobuf 3 writes in both runs, so the text verdict differs between profiles without a native difference of that size.

Possible regressions#

Four of the 80 candidate-versus-baseline intervals, 40 per profile, lie entirely above 1:

ProfileWorkloadOperationCandidate/baseline
3scalarstreamCopy1.053 (1.019, 1.088)
3scalarobjectCopyReuse1.023 (1.003, 1.044)
3largedeserialize1.004 (1.000, 1.008)
4nestedstreamCopy1.036 (1.021, 1.051)

Object copy runs identical code in both builds, and a 64 KiB deserialize takes the unchanged streaming path, so those two intervals show the run-to-run drift between sequential runs on one VM. Stream copy changed only in how it obtains the same 4 KiB array; the two stream-copy cells are within about 5% and are not consistent across workloads or profiles. They are recorded as observed and are not attributed to the change.

Limitations#

The runs were sequential on one VM per profile, so host drift over about five hours on the Protobuf 4 VM and two and a half on the Protobuf 3 VM can affect the native times; the Chill lane controls part of it within each block. Five blocks per run give limited information about the distribution of ratios. The VM results are not compared with the 0.1.0 Mac baseline , which used a different machine. The corpus and warm setup do not cover first-use costs, other Flink or JDK versions, or record shapes outside the five workloads.

Retained evidence#

The data inventory lists the six result archives, their checksums, and how to reconstruct each measured tree.