Skip to content

Write nested length prefixes from the cached size - #45

Merged
merlimat merged 1 commit into
streamnative:masterfrom
merlimat:write-child-size
Oct 3, 2026
Merged

merlimat merged 1 commit into
streamnative:masterfrom
merlimat:write-child-size

Conversation

@merlimat

@merlimat merlimat commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Serializing Pulsar's BaseCommand is about 13% slower on master than in 0.8.2: 22.8 → 25.9 ns (PulsarApiBenchmark.lightProtoSerializeBaseCommand, JDK 26, median of 29 forks each, run interleaved). Every build has fast and slow forks on this benchmark, and master's usual one is about 3 ns slower than 0.8.2's. Bisecting points to #43. A profile puts the difference in the self time of BaseCommand._writeTo(), whose bytecode #43 didn't change: 2.7 ns in a fork of the build before #40, 4.4 ns on master.

Within _writeTo(), #43 only changed the nested message's getSerializedSize(), which _writeTo() calls for each length prefix and C2 inlines there. A write calls it twice per nested message: the size walk finds the cache empty and computes the size, then _writeTo() finds the size cached. With both outcomes in its profile, C2 compiles the nested message's size walk into the parent's _writeTo(), where it never runs. That was already true before #43, which changed the cache check and the stored value. Without hsdis on this machine I couldn't look at the generated assembly to say exactly why the result got slower.

Example

// BaseCommand._writeTo(byte[], int), generated on master
case 5 : {
    _i = LightProtoCodec.writeRawByte(_a, _i, _SEND_TAG);
    _i = LightProtoCodec.writeRawVarInt(_a, _i, send.getSerializedSize());  // inlined, size walk included
    _i = send._writeTo(_a, _i);
    break;
}

Change

Generated messages get an internal _sizeForWrite(). _writeTo() now uses it for every nested length prefix: singular, oneof, repeated and map message values, in both the byte[] and the NIO writer.

public int _sizeForWrite() {
    if (_cachedSize < -1) {
        return _cachedSize & Integer.MAX_VALUE;
    }
    return getSerializedSize();
}
  • Always cached when called from _writeTo(). writeTo(), toByteArray() and the gRPC marshaller compute the size of the whole tree before they call _writeTo(), so the branch is always taken there. C2 turns the fallback into an uncommon trap instead of compiling the size walk in.
  • Returns what getSerializedSize() would. Only getSerializedSize() stores a size with the sign bit set, and every change resets the cache to -1. The fallback covers a _writeTo() call made without computing the size first.
  • Unchanged: the size walk still calls getSerializedSize(), and parseFrom() is bytecode-identical in the benchmarked messages.

Benchmark

JDK 26.0.1, Apple M1 Max, master (9f7549c) and this change in interleaved forks, median ns/op:

Benchmark Forks master this PR Δ
Pulsar BaseCommand serialize 10 26.1 24.6 −5.7%
Pulsar MessageMetadata serialize 6 68.7 68.2 −0.7%
AddressBook serialize 6 39.7 40.8 +2.8%
AddressBook fill + serialize 6 69.1 65.3 −5.5%
Frame serialize 6 6.7 6.7 0%
  • BaseCommand: this recovers about half of the gap. 0.8.2 measured 22.8 ns in the runs that found the regression.
  • AddressBook serialize: writes one unchanged message over and over. The profile spreads the extra 1.1 ns over the write path, about 0.3 ns of it in _sizeForWrite() against master's getSerializedSize(). The row ends about where 0.8.2 was (40.5 ns in those runs).

Testing

New SizeForWriteTest calls _writeTo() on messages whose sizes were never computed, so every nested prefix takes the fallback. It covers singular and repeated message fields, a message two levels down, map message values (also inside a nested message), and a oneof message. Each is written to a byte[] and to a direct ByteBuffer and compared with protobuf-java. All 6 cases pass before and after this change. Returning the cached size without the fallback fails all 6. mvn verify passes: 544 tests in each surefire run.

@merlimat
merlimat merged commit 9acdc10 into streamnative:master Oct 3, 2026
1 check passed
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