Skip to content

Compute the payload size of packed varint fields once per serialization - #41

Merged
merlimat merged 1 commit into
streamnative:masterfrom
merlimat:packed-size-cache
Oct 2, 2026
Merged

merlimat merged 1 commit into
streamnative:masterfrom
merlimat:packed-size-cache

Conversation

@merlimat

@merlimat merlimat commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Serializing a packed varint field walks its elements twice just to size it. getSerializedSize() sums the element sizes to size the payload, then _writeTo() sums them again to write the payload's length prefix. Packed is the default for repeated scalars in proto3, so this affects every proto3 message with a repeated integer or enum field, plus proto2 fields marked [packed = true].

Example

The generated _writeTo() for repeated int64 x_int64 = 2 [packed = true] repeats the loop that getSerializedSize() just ran:

int _xInt64sSize = 0;
for (int i = 0; i < _xInt64sCount; i++) {
    long _item = xInt64s[i];
    _xInt64sSize += LightProtoCodec.computeVarInt64Size(_item);
}
_i = LightProtoCodec.writeRawVarInt(_a, _i, _xInt64sSize);

Change

getSerializedSize() now keeps the payload size in a new field, and _writeTo() uses it:

int _xInt64sSize = _xInt64sPackedSize;
if (_xInt64sSize < 0) {
    // compute as before
    _xInt64sPackedSize = _xInt64sSize;
}

Why the cached value stays correct:

  • Every serialization runs getSerializedSize() just before _writeTo().
  • Every change to the elements (addX(), clearX(), clear(), copyFrom()) resets _cachedSize, so getSerializedSize() recomputes the payload size whenever the elements changed.
  • The exception is parseFrom(), which caches the message size without computing any field. So parsing the field resets its payload size to -1, and _writeTo() computes it and keeps it when it's unknown.

clear() is unchanged. Resetting the payload size there instead cost every parse one store per packed field, whether the field was present or not: +3.5% on a 10-element parse of a message with 7 packed fields.

Fixed-width packed fields already size their payload as count × width. So only messages with packed varint fields change; Pulsar's generated code is byte-identical.

JDK 26, one packed int64 field, 3 interleaved rounds (median ns/op):

Benchmark Elements Before After Δ
writeTo, size already cached 10 34.1 28.3 −17%
1200 4761 3840 −19%
writeTo after a change 10 43.8 37.8 −14%
1200 5883 4808 −18%
parse 10 39.7 40.2 +1%
1200 5062 5059 0%

Testing

The new PackedSizeTest changes the elements after the size was cached in six ways: adding, clearX(), clear(), parsing, re-serializing a parsed message and then adding, and copyFrom(). Each case runs on both write paths (heap array, and the NIO view of a direct buffer) and is compared byte for byte with protobuf-java. Without the parse-time reset, the parse case fails on both paths. mvn verify passes (554 tests).

getSerializedSize() walks the elements of a packed varint field to size
its payload, and _writeTo() walked them again for the payload's length
prefix.

getSerializedSize() now keeps the payload size in a field, and _writeTo()
uses it. Every serialization runs getSerializedSize() right before
_writeTo(), and every change to the elements resets _cachedSize, so
getSerializedSize() recomputes the payload size whenever the elements
changed. parseFrom() caches the message size without computing any
field, so parsing the field resets its payload size, and _writeTo()
computes and keeps it when it is unknown. clear() is unchanged:
resetting there cost every parse one store per packed field, present or
not.

Fixed-width packed fields already size their payload as count times
width, so only messages with packed varint fields change; proto3
repeated scalars are packed by default.

JDK 26, one packed int64 field: writeTo() with the size already cached
4761 -> 3840 ns for 1200 values and 34.1 -> 28.3 ns for 10, after a
change 5883 -> 4808 ns and 43.8 -> 37.8 ns. Parsing is within 1%.
@merlimat
merlimat merged commit 7384f8b into streamnative:master Oct 2, 2026
1 check passed
@merlimat
merlimat deleted the packed-size-cache branch October 2, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant