Skip to content

Make message clear() O(1) with presence-guarded getters - #11

Merged
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:optimize-message-clear
Jul 29, 2026
Merged

Make message clear() O(1) with presence-guarded getters#11
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:optimize-message-clear

Conversation

@merlimat

Copy link
Copy Markdown
Collaborator

Motivation

Messages are designed to be pooled and reused, and parseFrom() begins with clear() — which physically reset every field back to its default. In CPU profiles of deserialization this is 13–23% of total time: a generated MessageMetadata.clear() executed ~60 field stores before every parse, even when the incoming message only sets a handful of fields.

Changes

clear() now only resets presence state — bitfields, oneof cases, repeated counts, _cachedSize, _parsedBuffer — and stale field values are made unobservable instead of being reset:

  • Presence bits for all non-repeated, non-oneof fields. proto3 implicit-presence fields get an internal bit (no has() method is exposed) that marks "written since last clear".
  • Presence-guarded getters return the field default while presence is unset. MessageMetadata.clear() drops from ~60 stores to 6.
  • String/bytes invalidation moved into parse(): the cached decoded String/ByteBuf reference is dropped when the field is present on the wire, so the cost is paid per present field instead of per declared field.
  • equals()/hashCode() mask bitfields to explicit-presence bits (_PRESENCE_CMP_MASK), preserving proto3 semantics: "explicitly set to the default" equals "never set".
  • copyFrom() guards implicit-presence fields on the source's presence bit, matching protobuf merge semantics.
  • Repeated-message and map clear() reset only the count. The parse path adds elements via an internal _addXForParse() that skips clearing (the child's parseFrom() clears it anyway — previously each reused child was cleared twice per cycle), while the public addX() clears reused pooled instances before handing them out.
  • Map entries whose value field is absent from the wire get their pooled message slot reset up front, so they can't expose data from a previous cycle.

Latent bugs fixed (caught by the new InstanceReuseTest, verified failing on the previous generator)

  1. clearX() on a message field cleared the presence bit before the has()-guarded child clear, so the child was never cleared and getX() returned stale data.
  2. copyFrom() copied implicit-presence fields unconditionally, overwriting target fields with unset (default) source values.

Results

JMH deserialization throughput (Apple M-series, higher is better):

Benchmark before after Δ
AddressBook 19.4 ops/µs 26.3 ops/µs +35%
Pulsar MessageMetadata 14.3 ops/µs 17.4 ops/µs +22%
Simple + readString 38.6 ops/µs 51.3 ops/µs +33%
Pulsar BaseCommand 33.2 ops/µs 34.2 ops/µs +3%

Zero steady-state allocation on the parse path is unchanged (verified with -prof gc, ≈10⁻⁴ B/op).

One tradeoff to be aware of: since values are no longer physically reset, a cleared pooled instance can keep a previously-set String/ByteBuf referenced until the next set/parse of that field. This is never observable through the API (every read path is presence-guarded), only a memory-retention consideration comparable to the existing _parsedBuffer behavior.

Verification

  • Full test suite: 273 tests green (262 existing + 11 new).
  • New InstanceReuseTest covers the reuse hazards directly: wide→narrow reparse, stale decoded strings, pooled repeated/map element reuse, oneof case switching, materialize() on absent fields, proto3 set-to-default vs unset equality, and copyFrom merge semantics.

parseFrom() begins with clear(), which physically reset every field:
13-23% of deserialization time in CPU profiles (a generated
MessageMetadata.clear() executed ~60 field stores per parse).

clear() now only resets presence state (bitfields, oneof cases,
repeated counts, _cachedSize, _parsedBuffer):

- Presence bits are tracked for all non-repeated, non-oneof fields;
  proto3 implicit-presence fields get an internal bit (no has() method)
  marking "written since last clear".
- Getters return the field default while presence is unset, so stale
  values from previous parse/set cycles are unobservable.
- String/bytes fields invalidate their cached decoded value in parse()
  instead of clear(), paying the cost only for fields present on the wire.
- equals()/hashCode() mask bitfields to explicit-presence bits so that
  proto3 "set to default" remains indistinguishable from "never set".
- copyFrom() guards implicit-presence fields on the source's presence
  bit (protobuf merge semantics).
- Repeated message and map clear() reset only the element count; the
  parse path adds elements without clearing (parseFrom clears the child
  itself), while the public addX() clears reused pooled instances.

This also fixes two latent bugs, now covered by InstanceReuseTest:
- clearX() on a message field cleared the presence bit before the
  has()-guarded child clear, leaving a stale child reachable via getX().
- copyFrom() overwrote target fields with unset implicit-presence
  source values.

Deserialization throughput (JMH, Apple M-series):
  AddressBook            19.4 -> 26.3 ops/us  (+35%)
  Pulsar MessageMetadata 14.3 -> 17.4 ops/us  (+22%)
  Simple + readString    38.6 -> 51.3 ops/us  (+33%)
  Pulsar BaseCommand     33.2 -> 34.2 ops/us   (+3%)
Copilot AI review requested due to automatic review settings July 29, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Java code generator to make Message.clear() effectively O(1) by resetting only presence/state while ensuring stale pooled values remain unobservable via presence-guarded read paths. It also adds a comprehensive regression test suite to validate instance reuse safety across parse/clear cycles.

Changes:

  • Introduces internal presence bits for proto3 implicit-presence scalars and updates generated getters/materialize paths to return defaults when absent.
  • Moves per-field invalidation of cached decoded String / ByteBuf references into the parse path and reduces per-element clearing for repeated/map fields.
  • Adds InstanceReuseTest to cover reuse hazards (wide→narrow reparses, stale lazy-decode caches, repeated/map pooling, oneof switching, equality semantics, copy/merge behavior).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/src/test/java/io/streamnative/lightproto/tests/InstanceReuseTest.java New regression tests validating pooled instance reuse correctness under the new clear/presence model.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoField.java Generates presence masks for all non-repeated/non-oneof fields and introduces presenceCondition() used by guarded getters/materialize.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessage.java Sets internal presence bits during parse, masks bitfields for equals/hashCode proto3 semantics, and changes implicit-presence copyFrom() behavior.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java Adds presence-guarded getters; adjusts equals/hashCode to avoid stale values for implicit-presence numerics.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java Adds presence-guarded getter and stale-safe equals/hashCode for implicit presence.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java Adds presence-guarded getter and stale-safe equals/hashCode for implicit presence enums.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java Guards getter/materialize by presence; invalidates cached decoded string on parse.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java Guards getters/materialize by presence; invalidates cached buffer reference on parse.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedStringField.java Stops per-element clear; drops stale decoded string in pooled holder on parse.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedBytesField.java Stops per-element clear; drops stale buffer reference in pooled holder on parse.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoRepeatedMessageField.java Avoids double-clearing pooled children by using an internal add-for-parse path; ensures public add clears before handing out.
code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMapField.java Resets pooled message-value slots during parse and removes per-entry clear in map clear().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 211 to +218
if (f.isRepeated()) {
f.copy(w);
} else if (f.field.hasImplicitPresence()) {
// Always copy for proto3 implicit presence — no has() to check
// No public has() — guard on the other message's internal presence bit,
// since its field value may be stale when the bit is unset.
w.format(" if ((_other._bitField%d & %s) != 0) {\n", f.bitFieldIndex(), f.fieldMask());
f.copy(w);
w.format(" }\n");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in e4a02aa. The copy condition for implicit-presence fields now also requires the source value to be non-default (same expression as the serialization condition), matching protobuf-java's mergeFrom behavior. Added testCopyFromTreatsSetToDefaultAsUnset to pin it down.

Review feedback: copyFrom() guarded implicit-presence fields only on the
source's internal presence bit, so a field explicitly set to its default
(e.g. setIntField(0)) would overwrite a non-default target value. proto3
merge semantics treat default == unset (protobuf-java mergeFrom checks
`other.getX() != 0`), and the field would not survive a serialize/parse
roundtrip either.

The copy condition now also requires the source value to be non-default,
matching the serialization condition.
Copilot AI review requested due to automatic review settings July 29, 2026 10:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

@merlimat
merlimat merged commit 79213e6 into streamnative:master Jul 29, 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.

2 participants